Spec Decision Record — Articles & Favorites
Feature spec for the article vertical slice. Derives from
docs/PRD.md(articles, favorites) and reuses the ports & adapters decision ofdocs/adrs/ADR-0002-auth.md. Conforms to the RealWorld Conduit API article contract, with one deliberate performance deviation (list/feed omitbody).
Endpoints
| Method | Path | Auth | Success | Body in list/feed |
|---|---|---|---|---|
| GET | /api/articles | optional | 200 | omitted |
| GET | /api/articles/feed | required | 200 | omitted |
| GET | /api/articles/:slug | optional | 200 | included |
| POST | /api/articles | required | 201 | included |
| PUT | /api/articles/:slug | required (author) | 200 | included |
| DELETE | /api/articles/:slug | required (author) | 200 | — ({}) |
| POST | /api/articles/:slug/favorite | required | 200 | included |
| DELETE | /api/articles/:slug/favorite | required | 200 | included |
Authorization: Token <jwt> is the RealWorld scheme; Bearer is also accepted.
Behaviour
- List/feed omit
body(performance spec change, 2024-08-16). The single presenter splits a body-less summary from the full article; only the single-article, create, update, and favorite envelopes carrybody. favoritedreflects whether the viewing user has favorited the article (falsewhen anonymous);favoritesCountis derived from the favorites graph, never stored.author.followingreflects the viewer’s follow edge, reusing the sameIProfileRepository.isFollowingthe profile slice uses.- Filters (
tag,author,favorited) and pagination (limitdefault 20, max 100;offsetdefault 0) apply toGET /api/articles. The service resolves theauthor/favoritedusernames to ids; a filter naming a user who does not exist yields an empty page, not a 404 (RealWorld semantics).articlesCountis the total matching count before pagination. - Feed returns articles authored by the users the viewer follows, newest first. The service resolves the followed ids via
IProfileRepositoryand passes them to the article port, which therefore never touches the follow graph. - Slugs are derived from the title (
slugify: lower-cased, accent-stripped, hyphenated). On collision a short suffix is appended, so the slug is unique. Changing the title on update re-derives the slug. - Favorite / unfavorite are idempotent, mirroring follow/unfollow.
Status code & authorization decisions
Following the codebase’s error taxonomy (src/errors/AppError.ts) and the precedent in docs/specs/auth.md / docs/specs/profiles.md:
- Create →
201; all other successes →200(favorite is an idempotent state assertion, not a fresh resource). DELETE returns200 {}. - Unknown
:slug→404(NotFoundError, resourceArticle). - Auth at the router seam:
authenticateon create/update/delete/feed/ favorite;optionalAuthenticateon the public reads so theirfavorited/followingflags can reflect a known caller. Auth is never checked inside handlers (.claude/standards/api.md). - Author-only mutation →
403(ForbiddenError): the service comparesarticle.authorIdto the caller, so the rule holds regardless of transport. - Validation →
422with field errors, via Zod at the boundary (articleSchemas.ts+ the sharedvalidation.tshelper).
The soft-delete decision (operation-classification Tier 3)
docs/operation-classification.md classes hard delete of domain entities as Tier 3 (“use soft delete + audit log instead”). The PRD requires a working DELETE /api/articles/:slug whose article disappears from subsequent reads. We reconcile the two with a soft delete: delete sets deletedAt, and every read path (findBySlug, list, feed) filters deletedAt: null. The observable RealWorld behaviour (gone, 404 on re-GET) is identical, while the row is retained — no hard delete, scoped by a specific slug (never an unscoped DELETE). The in-memory fake mirrors this exclusion contract.
Architecture (ports & adapters)
HTTP (articleRoutes/ArticleController) → ArticleService → ports
· optionalAuthenticate (list, get) ├─ IArticleRepository → Prisma / InMemory
· authenticate (feed, create, update, delete, favorite) ├─ IUserRepository → resolve author/favoriter usernames → ids
└─ IProfileRepository → author `following` + feed author ids
No new ADR is raised: this slice reuses ADR-0002’s layering and injection pattern. The scaffolded IArticleRepository port was refined so filters/feed arrive as ids (not usernames) — keeping the article adapter ignorant of users and the follow graph (no lateral coupling between adapters); username → id resolution is the service’s job. The shared boundary-validation helper parseOrThrow was extracted to a domain-neutral src/http/validation.ts so the article and user schema modules reuse it without a lateral cross-domain import.
Test strategy
- Unit:
slugify(pure);InMemoryArticleRepository(ordering, filters, pagination total, derived count, idempotent favorite, soft-delete exclusion);ArticleService(slug derivation/uniqueness, author-only mutation, filter resolution incl. unknown-user empty page, feed, favoriting, viewer-relative flags). - Integration (subcutaneous): Supertest against the real Express app over the in-memory fakes — all eight endpoints, 401/403/404/422 paths, pagination, filtering, and the
body-omission contract on list & feed. - Integration (real DB):
PrismaArticleRepositoryis verified by the gated real-database suite (RUN_DB_TESTS=1) and excluded from the unit-coverage denominator, exactly as the other Prisma adapters are. - Coverage after this slice: 97.3% stmts / 87.0% branch (gate 80% / 80%).