@stjoseph/prumo 0.0.6 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/.prumo-templates/api/auth.md +51 -95
  2. package/.prumo-templates/api/config.md +30 -56
  3. package/.prumo-templates/api/errors.md +64 -44
  4. package/.prumo-templates/api/logging.md +27 -44
  5. package/.prumo-templates/api/observability.md +36 -62
  6. package/.prumo-templates/api/pagination.md +23 -38
  7. package/.prumo-templates/api/routes.md +67 -48
  8. package/.prumo-templates/api/structure.md +130 -0
  9. package/.prumo-templates/client/data.md +4 -3
  10. package/.prumo-templates/core/conventions.md +10 -7
  11. package/.prumo-templates/core/testing.md +43 -26
  12. package/.prumo-templates/core/tooling.md +33 -30
  13. package/.prumo-templates/database/entities.md +53 -61
  14. package/.prumo-templates/database/queries.md +34 -47
  15. package/.prumo-templates/database/transactions.md +28 -38
  16. package/.prumo-templates/mcp/server.md +54 -0
  17. package/.prumo-templates/monorepo/dependencies.md +3 -3
  18. package/.prumo-templates/monorepo/layout.md +1 -1
  19. package/README.md +6 -6
  20. package/dist/asker.js +4 -0
  21. package/dist/cli.js +4 -0
  22. package/dist/commands.js +2 -0
  23. package/dist/compose.js +6 -2
  24. package/dist/context.js +2 -0
  25. package/dist/generate.js +8 -0
  26. package/dist/mcp.js +100 -0
  27. package/dist/questions.js +21 -1
  28. package/package.json +4 -3
  29. package/templates/api/.swcrc +13 -0
  30. package/templates/api/biome.jsonc +5 -3
  31. package/templates/api/package.json +21 -27
  32. package/templates/api/pnpm-workspace.yaml +0 -2
  33. package/templates/api/src/domain/shared/errors/domain-error.ts +5 -0
  34. package/templates/api/src/domain/shared/transactions/transaction-manager.ts +5 -0
  35. package/templates/api/src/domain/users/dto/profile-response.dto.ts +14 -0
  36. package/templates/api/src/domain/users/dto/update-profile.dto.ts +10 -0
  37. package/templates/api/src/domain/users/entities/profile.entity.ts +26 -0
  38. package/templates/api/src/domain/users/errors/profile-not-found.error.ts +9 -0
  39. package/templates/api/src/domain/users/repositories/profile.repository.ts +8 -0
  40. package/templates/api/src/domain/users/use-cases/create-profile.use-case.ts +19 -0
  41. package/templates/api/src/domain/users/use-cases/find-profile.use-case.ts +22 -0
  42. package/templates/api/src/domain/users/use-cases/update-profile.use-case.ts +26 -0
  43. package/templates/api/src/infra/auth/auth.cli.ts +5 -0
  44. package/templates/api/src/{auth → infra/auth}/auth.factory.ts +26 -1
  45. package/templates/api/src/infra/config/env.ts +47 -0
  46. package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +16 -0
  47. package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +28 -0
  48. package/templates/api/src/{mikro-orm.config.ts → infra/database/mikroorm/mikro-orm.config.ts} +1 -1
  49. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +17 -0
  50. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-profile.repository.ts +17 -0
  51. package/templates/api/src/infra/database/mikroorm/transactions/mikroorm-transaction-manager.ts +14 -0
  52. package/templates/api/src/infra/di/index.ts +32 -0
  53. package/templates/api/src/infra/di/users.di.ts +7 -0
  54. package/templates/api/src/infra/http/app.ts +99 -0
  55. package/templates/api/src/infra/http/controllers/auth.controller.ts +36 -0
  56. package/templates/api/src/infra/http/controllers/health.controller.ts +19 -0
  57. package/templates/api/src/infra/http/controllers/mcp.controller.ts +40 -0
  58. package/templates/api/src/infra/http/controllers/users.controller.ts +35 -0
  59. package/templates/api/src/infra/http/errors/error-handler.ts +116 -0
  60. package/templates/api/src/infra/http/errors/http-error.ts +8 -0
  61. package/templates/api/src/infra/http/fastify.d.ts +17 -0
  62. package/templates/api/src/infra/http/fetch-bridge.ts +42 -0
  63. package/templates/api/src/infra/http/hooks/auth.hook.ts +32 -0
  64. package/templates/api/src/infra/http/hooks/request-context.hook.ts +9 -0
  65. package/templates/api/src/infra/http/server.ts +34 -0
  66. package/templates/api/src/infra/mcp/mcp.server.ts +20 -0
  67. package/templates/api/src/infra/mcp/tools/users.tool.ts +39 -0
  68. package/templates/api/test/domain/users/use-cases/find-profile.use-case.spec.ts +22 -0
  69. package/templates/api/test/domain/users/use-cases/update-profile.use-case.spec.ts +26 -0
  70. package/templates/api/{src → test/infra}/auth/auth.factory.spec.ts +4 -11
  71. package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +8 -0
  72. package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-profile.repository.spec.ts +51 -0
  73. package/templates/api/test/infra/database/mikroorm/transactions/mikroorm-transaction-manager.spec.ts +29 -0
  74. package/templates/api/test/infra/http/app.spec.ts +21 -0
  75. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +152 -0
  76. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +105 -0
  77. package/templates/api/test/support/factories/profile.factory.ts +25 -0
  78. package/templates/api/test/{factories → support/factories}/user.factory.ts +1 -1
  79. package/templates/api/test/support/fakes/in-memory-profile.repository.ts +14 -0
  80. package/templates/api/test/{setup.ts → support/setup.ts} +26 -14
  81. package/templates/api/tsconfig.json +3 -6
  82. package/templates/api/vitest.config.mts +4 -4
  83. package/templates/mobile/jest.config.js +1 -1
  84. package/templates/mobile/package.json +2 -2
  85. package/templates/mobile/{src → test}/features/auth/authenticated-layout.spec.tsx +1 -1
  86. package/templates/mobile/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
  87. package/templates/mobile/{src → test}/features/auth/sign-out-button.spec.tsx +2 -2
  88. package/templates/mobile/{src → test}/features/profile/profile-form.spec.tsx +2 -2
  89. package/templates/mobile/tsconfig.json +2 -1
  90. package/templates/site/{src → test}/app/page.spec.ts +1 -1
  91. package/templates/site/tsconfig.json +2 -1
  92. package/templates/site/vitest.config.mts +4 -2
  93. package/templates/web/package.json +4 -2
  94. package/templates/web/src/features/auth/auth-client.ts +5 -0
  95. package/templates/web/src/features/auth/sign-in-form.tsx +10 -3
  96. package/templates/web/src/features/oauth/consent-form.tsx +63 -0
  97. package/templates/web/src/routeTree.gen.ts +21 -2
  98. package/templates/web/src/routes/_authenticated/consent.tsx +21 -0
  99. package/templates/web/{src → test}/app.spec.tsx +2 -2
  100. package/templates/web/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
  101. package/templates/web/test/features/oauth/consent-form.spec.tsx +45 -0
  102. package/templates/web/{src → test}/features/profile/profile-form.spec.tsx +2 -2
  103. package/templates/web/test/{render-app.tsx → support/render-app.tsx} +1 -1
  104. package/templates/web/tsconfig.json +2 -1
  105. package/templates/web/vite.config.ts +3 -3
  106. package/.prumo-templates/api/modules.md +0 -117
  107. package/templates/api/nest-cli.json +0 -10
  108. package/templates/api/src/app.module.ts +0 -46
  109. package/templates/api/src/auth/auth.constants.ts +0 -2
  110. package/templates/api/src/auth/auth.controller.ts +0 -19
  111. package/templates/api/src/auth/auth.guard.ts +0 -43
  112. package/templates/api/src/auth/auth.module.ts +0 -30
  113. package/templates/api/src/auth/auth.ts +0 -4
  114. package/templates/api/src/auth/current-user.decorator.ts +0 -9
  115. package/templates/api/src/auth/express.d.ts +0 -10
  116. package/templates/api/src/auth/public.decorator.ts +0 -4
  117. package/templates/api/src/config/env.module.ts +0 -11
  118. package/templates/api/src/config/env.ts +0 -81
  119. package/templates/api/src/errors/problem-details.filter.ts +0 -86
  120. package/templates/api/src/errors/validation-exception.factory.ts +0 -30
  121. package/templates/api/src/health/health.controller.ts +0 -32
  122. package/templates/api/src/health/health.module.ts +0 -9
  123. package/templates/api/src/main.ts +0 -43
  124. package/templates/api/src/mikro-orm.factory.ts +0 -14
  125. package/templates/api/src/request-context/request-context.module.ts +0 -9
  126. package/templates/api/src/request-context/request-context.service.ts +0 -17
  127. package/templates/api/src/request-context/request-id.middleware.ts +0 -19
  128. package/templates/api/src/users/dto/update-profile.dto.ts +0 -20
  129. package/templates/api/src/users/entities/profile.entity.ts +0 -27
  130. package/templates/api/src/users/users.controller.ts +0 -23
  131. package/templates/api/src/users/users.module.ts +0 -10
  132. package/templates/api/src/users/users.service.spec.ts +0 -44
  133. package/templates/api/src/users/users.service.ts +0 -36
  134. package/templates/api/test/factories/profile.factory.ts +0 -26
  135. package/templates/api/tsconfig.build.json +0 -8
  136. /package/templates/api/test/{global-setup.ts → support/global-setup.ts} +0 -0
  137. /package/templates/mobile/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
  138. /package/templates/web/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
  139. /package/templates/web/test/{setup.ts → support/setup.ts} +0 -0
@@ -2,90 +2,64 @@
2
2
 
3
3
  ## Rule
4
4
 
5
- Expose two health routes with `@nestjs/terminus`. Liveness reports that the process is up and checks
6
- no dependency. Readiness checks that dependencies answer, the database included. Mark both `@Public()`
7
- and exclude them from request logging.
5
+ Expose two public health routes in `src/infra/http/controllers/health.controller.ts`. `/api/health/live`
6
+ checks nothing. `/api/health/ready` checks the database with `orm.checkConnection()` and answers 503 when it
7
+ is down.
8
8
 
9
- Connect the ORM explicitly at bootstrap, before the application listens.
9
+ Connect the ORM explicitly in `server.ts`, before the application listens.
10
10
 
11
- Do not install an error-reporting or tracing service by default. A project has structured logs and
12
- these two routes, and nothing else, until its owner chooses otherwise.
11
+ Do not install an error-reporting or tracing service by default. A project has structured logs and these two
12
+ routes, and nothing else, until its owner chooses otherwise.
13
13
 
14
- When Sentry is chosen, wire it exactly as follows and not otherwise.
14
+ When Sentry is chosen, wire it exactly as follows:
15
15
 
16
- Import `instrument.ts` as the first line of the bootstrap, before `NestFactory` and before every other
17
- import. Register `SentryModule` in the root module. Add `@SentryExceptionCaptured()` to the existing
18
- global exception filter's `catch()` method. Do not register `SentryGlobalFilter`.
19
-
20
- Send an event for any 5xx response: an unhandled exception and a deliberately thrown 5xx alike. Never
21
- send one for a 4xx, and never make a `logger.error` call create one by itself.
22
-
23
- Attach the request id as a **tag**, and the user's identifier when a session exists. Leave PII sending
24
- off. Never attach a request body.
16
+ - Install `@sentry/node`. Call `Sentry.init` in `src/infra/observability/instrument.ts`, with
17
+ `Sentry.fastifyIntegration()` in `integrations`, and import that file as the first line of `server.ts`,
18
+ before `reflect-metadata`.
19
+ - Give `fastifyIntegration` a `shouldHandleError` that uses the status the error handler resolves, so only a
20
+ 5xx becomes an event. Export that mapping from `error-handler.ts` rather than writing a second one.
21
+ - Do not call `setupFastifyErrorHandler`: in `@sentry/node` 11 it does nothing.
22
+ - Attach the request id as a tag, and the user's id when a session exists. Leave PII sending off. Never
23
+ attach a request body.
25
24
 
26
25
  ## Rationale
27
26
 
28
- Liveness and readiness answer opposite questions (*should I restart this process* and *should I send
29
- it traffic*) and a single endpoint forces one answer to both. The failure that causes is routine: the
30
- endpoint checks the database, the database wobbles, and the orchestrator restarts a healthy process.
31
- Restarting does not fix a database, and the wobble becomes an outage.
27
+ Liveness and readiness answer opposite questions, *should I restart this process* and *should I send it
28
+ traffic*, and one endpoint forces one answer to both. The routine failure: the endpoint checks the database,
29
+ the database wobbles, and the orchestrator restarts a healthy process.
32
30
 
33
- **MikroORM 7's `init` no longer connects**, and the readiness check asks whether a connection exists. Without
34
- the explicit connect, readiness reports the database down until a first query, and an orchestrator sends no
35
- query to an instance that is not ready. The instance never becomes ready. Connecting at bootstrap also means
36
- an unreachable database fails the boot instead of a request.
31
+ **MikroORM 7's `init` no longer connects.** Without the explicit connect, readiness reports the database down
32
+ until a first query, and no query reaches an instance that is not ready. Connecting first also means an
33
+ unreachable database fails the boot instead of a request.
37
34
 
38
- Error reporting is not installed because it requires a commercial account. Installing a service that
39
- needs an account, a DSN and eventually a bill decides something commercial on the owner's behalf,
40
- inside a project that is theirs. Free tooling can simply be deleted; a SaaS dependency is different in
41
- kind. So the wiring is written down and ready, and absent until wanted.
35
+ Error reporting is not installed because it requires a commercial account: an account, a DSN and eventually a
36
+ bill is a commercial decision made on the owner's behalf. The wiring is written down and absent until wanted.
42
37
 
43
38
  `Sentry.init` must run before other modules load, because the instrumentation patches them as they are
44
- imported. Nothing enforces that ordering, which makes it the most fragile line in the project.
45
-
46
- The 5xx boundary is the status, not the exception type: a deliberately thrown `ServiceUnavailableException`
47
- is a real failure somebody must see, and treating it as routine because it was explicit would hide the
48
- case that matters most. Sending everything instead turns the issue list into a stream of invalid client
49
- input until somebody mutes the project.
39
+ imported. The status comes from our mapping because a domain error carries a `kind`, not a status: a filter
40
+ reading the error alone would count a 404 as a 500 and fill the issue list with invalid client input.
50
41
 
51
- **What reaches Sentry leaves your perimeter.** A log stays in infrastructure you control; a third-party
52
- service does not, and is readable by anyone with access to that project. That is why the bar here is
53
- higher than for logs, and why the body never goes: on an authentication route it is the password, on a
54
- payment route it is the card. Reproduction is a convenience; a leak into someone else's system is an
55
- incident.
42
+ **What reaches Sentry leaves your perimeter**, readable by anyone with access to that project. That is why
43
+ the body never goes: on an authentication route it is the password.
56
44
 
57
45
  ## Applies to
58
46
 
59
- The bootstrap, the health module, and the global exception filter.
47
+ `server.ts`, the health controller, and `src/infra/observability/` once error reporting is installed.
60
48
 
61
49
  ## Examples
62
50
 
63
- Health routes:
64
-
65
- ```
66
- ✅ GET /api/health/live → process is up, checks nothing
67
- GET /api/health/ready → database answers
68
- ❌ GET /api/health → used for both
69
51
  ```
52
+ ✅ GET /api/health/live process is up, checks nothing
53
+ GET /api/health/ready database answers
54
+ ❌ GET /api/health used for both
70
55
 
71
- Wiring Sentry into the existing filter:
72
-
73
- ```
74
- ✅ @Catch()
75
- export class ProblemFilter implements ExceptionFilter {
76
- @SentryExceptionCaptured()
77
- catch(exception: unknown, host: ArgumentsHost) {}
78
- }
79
-
80
- ❌ app.useGlobalFilters(new SentryGlobalFilter(), new ProblemFilter())
56
+ ✅ Sentry.fastifyIntegration({ shouldHandleError: (error) => statusOf(error) >= 500 })
57
+ ❌ Sentry.setupFastifyErrorHandler(app)
81
58
  ```
82
59
 
83
60
  ## Enforcement
84
61
 
85
- **Boot.** The health routes and the filter are registered once.
86
-
87
- **Review only, and this is the weakest line in the project:** that `instrument.ts` stays the first
88
- import. Reordering imports for tidiness, or a formatter doing it unasked, silently stops the
89
- instrumentation, and nothing fails.
62
+ **Boot.** The health routes are registered once, and the ORM connects before listening.
90
63
 
91
- Also review: that no event carries a body, and that a 4xx never becomes one.
64
+ **Review only, and this is the weakest line in the project:** that `instrument.ts` stays the first import.
65
+ Reordering imports, or a formatter doing it unasked, silently stops the instrumentation, and nothing fails.
@@ -6,66 +6,51 @@ Paginate every list endpoint by cursor. The UUID v7 primary key is the cursor.
6
6
 
7
7
  Order by id descending. `after` means *older than this*. Do not accept a sort parameter.
8
8
 
9
- Accept two query parameters, declared on a query DTO: `limit`, defaulting to 20 and carrying
10
- `@Max(100)`, and `after`.
9
+ Accept two query parameters in the route's `querystring` schema, a `z.strictObject`:
10
+ `limit: z.coerce.number().int().min(1).max(100).default(20)` and `after: z.uuid().optional()`.
11
11
 
12
- Return `{ "data": [...], "nextCursor": "01J..." | null }`. Add nothing else: no `hasMore`, no
13
- `total`, no nested `meta`.
12
+ Return `{ "data": [...], "nextCursor": "0199..." | null }`. Add nothing else: no `hasMore`, no `total`, no
13
+ nested `meta`.
14
14
 
15
15
  Set `nextCursor` to the id of the last item on the page, or `null` when the page is the last one.
16
16
 
17
17
  ## Rationale
18
18
 
19
- Offset pagination fails silently. Under concurrent inserts, page 2 repeats an item from page 1 or
20
- skips one (no error, just a wrong list) and it degrades at depth, because the database counts and
21
- discards every skipped row. A cursor is stable at any depth, and UUID v7 being time-ordered means the
22
- id already is one: no extra sort column, no extra index.
19
+ Offset pagination fails silently. Under concurrent inserts, page 2 repeats an item from page 1 or skips one,
20
+ with no error, and it degrades at depth, because the database counts and discards every skipped row. A cursor
21
+ is stable at any depth, and UUID v7 being time-ordered means the id already is one.
23
22
 
24
- That choice fixes the order. `WHERE id < :cursor ORDER BY id DESC` only works over one ordering, so a
25
- sort parameter would break the cursor without erroring. Descending, because that is what a list screen
26
- shows.
23
+ That fixes the order: `WHERE id < :cursor ORDER BY id DESC` only works over one ordering, so a sort parameter
24
+ would break the cursor without erroring.
27
25
 
28
- `hasMore` is omitted because a null `nextCursor` already says it, and a field derived from another
29
- field can disagree with it. `total` is omitted because it costs a `COUNT` on every request: the most
30
- expensive part of an otherwise cheap endpoint, paid whether or not anyone reads it. Adding it to one
31
- route later breaks no client.
26
+ `hasMore` is omitted because a null `nextCursor` already says it. `total` is omitted because it costs a
27
+ `COUNT` on every request, paid whether or not anyone reads it; adding it to one route later breaks no client.
32
28
 
33
- The parameters are an ordinary query DTO, so validation already covers them and the maximum is a
34
- decorator rather than clamping logic. Clamping would return 100 rows to a client that asked for 500
35
- without saying so, and the client would advance as though it had consumed 500.
29
+ The maximum lives in the schema rather than in clamping logic. Clamping would return 100 rows to a client
30
+ that asked for 500 without saying so.
36
31
 
37
32
  ## Applies to
38
33
 
39
- Every endpoint returning a collection. Filtering is not covered here: a filter is a query DTO field
40
- like any other, and it composes with the cursor without special handling.
34
+ Every endpoint returning a collection. A filter is another field of the same query schema and composes with
35
+ the cursor.
41
36
 
42
37
  ## Examples
43
38
 
44
- The response:
45
-
46
39
  ```
47
- ✅ { "data": [ ... ], "nextCursor": "01JQ8Z..." }
40
+ ✅ { "data": [ ... ], "nextCursor": "0199..." }
48
41
  ❌ { "data": [ ... ], "meta": { "page": 2, "total": 3204, "hasMore": true } }
49
- ```
50
42
 
51
- The query DTO:
43
+ ✅ limit: z.coerce.number().int().min(1).max(100).default(20)
44
+ ❌ limit: z.coerce.number().default(20) no maximum: ?limit=1000000
52
45
 
53
- ```
54
- ✅ @Max(100) limit: number = 20
55
- ❌ limit: number = 20 // no maximum; ?limit=1000000 is a denial of service
56
- ```
57
-
58
- Ordering:
59
-
60
- ```
61
- ✅ GET /api/v1/orders?limit=20&after=01JQ8Z...
46
+ ✅ GET /api/v1/orders?limit=20&after=0199...
62
47
  ❌ GET /api/v1/orders?sort=total&page=3
63
48
  ```
64
49
 
65
50
  ## Enforcement
66
51
 
67
- **Validation.** `limit`'s bounds are enforced by the pipe, and an unknown parameter (`page`, `sort`,
68
- `offset`) is already rejected because unknown properties are forbidden.
52
+ **Validation.** `limit`'s bounds are enforced by the schema, and an unknown parameter (`page`, `sort`,
53
+ `offset`) is rejected by `z.strictObject`.
69
54
 
70
- **Review only.** That `nextCursor` is the last item's id and is null on the final page, and that a new
71
- list endpoint paginates at all rather than returning an unbounded array.
55
+ **Review only.** That `nextCursor` is the last item's id and is null on the final page, and that a new list
56
+ endpoint paginates at all.
@@ -2,88 +2,107 @@
2
2
 
3
3
  ## Rule
4
4
 
5
- Set a global prefix of `api` and enable `VersioningType.URI`. Every route is reachable at
6
- `/api/v<n>/<resource>`.
5
+ Serve every route at `/api/v<n>/<resource>`: register each controller with
6
+ `prefix: '/api/v<n>/<resource>'`. Better Auth's `/api/auth/*`, `/api/health/*` and `/api/docs` are the only
7
+ routes outside a version.
7
8
 
8
- Name resources in the plural, kebab-case for compound words. Use REST verbs and Nest's default status
9
- codes.
9
+ Name resources in the plural, kebab-case for compound words. Use REST verbs. A `POST` answers `201`
10
+ through `reply.code(201)`; every other method answers `200`.
10
11
 
11
- Put input DTOs in `dto/`, named as the generator names them: `CreateUserDto`, `UpdateUserDto`.
12
+ Write one controller per module, `src/infra/http/controllers/<module>.controller.ts`, as a Fastify plugin
13
+ declared with `function`. Its handler reads the request, resolves one use case from the container, calls
14
+ `execute()` and returns the result. Nothing else happens in a handler.
12
15
 
13
- Configure the global `ValidationPipe` with `whitelist: true`, `forbidNonWhitelisted: true` and
14
- `transform: true`. Leave `enableImplicitConversion` off; convert a field that needs it with `@Type()`.
16
+ Declare every route's schemas from the module's `dto/`: `body`, `querystring` and `params` for what enters,
17
+ `response` for what leaves, keyed by status. Write input schemas with `z.strictObject`, output schemas with
18
+ `z.object`. Convert a query or path value that is not a string with `z.coerce`, in the schema.
15
19
 
16
- Return the entity. Serialize it with `ClassSerializerInterceptor`, marking with `@Exclude` every
17
- property that must not leave. Where the value arrives as a plain object, add
18
- `@SerializeOptions({ type: Entity })`. Do not write a response DTO per route.
20
+ Write one output schema per shape the API exposes, named after the entity: `profile-response.dto.ts`. Every
21
+ route and every MCP tool returning that shape uses it. Write another only when the shape differs, and name
22
+ the difference: `profile-summary-response.dto.ts`. Declare dates in an output schema as `z.date()`.
19
23
 
20
- A controller receives the request, calls the service and returns. Everything else on it is a
21
- decorator.
22
-
23
- Enable the `@nestjs/swagger` CLI plugin. Decorate by hand only what it cannot infer: error responses,
24
- non-obvious status codes, route descriptions.
24
+ Tag every route with its module. Describe by hand only what a schema cannot say: a non-obvious status, an
25
+ error a route answers on purpose.
25
26
 
26
27
  Serve the documentation UI outside production only.
27
28
 
28
29
  ## Rationale
29
30
 
30
- The global prefix makes Better Auth's `/api/auth/*` fall out of the ordinary convention instead of
31
- becoming the one route with an exception in its path.
31
+ The prefix makes Better Auth's `/api/auth/*` fall out of the ordinary convention instead of becoming the
32
+ one route with an exception in its path.
33
+
34
+ Versioning costs five characters today and a coordinated migration of every consumer later, including a
35
+ published mobile app that does not update when you want it to. It goes in the URI rather than a header
36
+ because a version in the URL appears in logs, caches, a `curl` line and an error report: exactly where you
37
+ look when v1 breaks.
32
38
 
33
- Versioning costs five characters today and a coordinated migration of every consumer later, including
34
- a published mobile app that does not update when you want it to. It goes in the URI rather than a
35
- header because a version in the URL appears in logs, caches, a `curl` line and a Sentry report:
36
- exactly where you look when v1 breaks.
39
+ `z.strictObject` is what makes an unexpected property visible. Stripping it silently returns 200 to a
40
+ client that sent `role: "admin"`, which looks like success and hides the honest bug and the malicious
41
+ attempt equally.
37
42
 
38
- `forbidNonWhitelisted` is what makes an unexpected property visible. Stripping it silently returns 200
39
- to a client that sent `role: "admin"`, which looks like success and hides the honest bug and the
40
- malicious attempt equally.
43
+ On the way out the opposite is wanted, and `z.object` gives it: Fastify serializes through the response
44
+ schema and drops every field it does not list. The schema is therefore the list of what may leave. It is
45
+ written once per shape, because three schemas over one entity would each have to remember to leave out
46
+ `tenant_id`, and so would the fourth someone adds later. A route with no response schema would fall back
47
+ to `JSON.stringify` and publish every column, which is why the application refuses to start with one.
41
48
 
42
- Serialization is declared once on the entity rather than repeated in every response DTO that touches
43
- it. Three DTOs over one entity would each have to remember to omit `tenant_id`, and so would the
44
- fourth someone adds later.
49
+ A date is `z.date()` because the entity holds a `Date` and Fastify writes it as ISO 8601. `z.iso.datetime()`
50
+ and `.transform()` both fail at runtime on a `Date`, with a 500.
45
51
 
46
- The swagger plugin reads the class-validator decorators that already exist. Declaring the same field a
47
- third time by hand produces the copy nobody updates, and API documentation that lies is worse than
48
- none, because consumers trust it.
52
+ The OpenAPI document is generated from the same schemas the routes validate with. A description written by
53
+ hand beside them is the copy nobody updates, and documentation that lies is worse than none, because
54
+ consumers trust it.
55
+
56
+ `201` on `POST` is the status code clients already receive: the rule was Nest's default before, and is
57
+ written down now that no framework supplies it.
49
58
 
50
59
  ## Applies to
51
60
 
52
- Every controller under `src/`, and the application bootstrap where the prefix, versioning, pipe and
53
- documentation are configured.
61
+ Every controller under `src/infra/http/controllers/`, every schema under `src/domain/<module>/dto/`, and
62
+ `src/infra/http/app.ts`, where the prefixes, the compilers and the documentation are registered.
54
63
 
55
64
  ## Examples
56
65
 
57
66
  Resource naming and versioning:
58
67
 
59
68
  ```
60
- ✅ @Controller({ path: 'billing-accounts', version: '1' })
61
- ❌ @Controller('billingAccount')
69
+ ✅ app.register(billingAccountsController, { prefix: '/api/v1/billing-accounts' })
70
+ ❌ app.register(billingAccountsController, { prefix: '/billingAccount' })
71
+ ```
72
+
73
+ What enters and what leaves:
74
+
75
+ ```
76
+ ✅ body: updateProfileSchema z.strictObject({ … })
77
+ response: { 200: profileResponseSchema }
78
+ ❌ body: z.object({ … }) an unknown field passes, silently dropped
79
+ ❌ { schema: { body: … } } no response: every column leaves
62
80
  ```
63
81
 
64
- Keeping a property out of a response:
82
+ A value from the query string:
65
83
 
66
84
  ```
67
- ✅ @Exclude() tenantId!: string
68
- ❌ tenantId!: string
85
+ ✅ page: z.coerce.number().int().min(1)
86
+ ❌ page: z.number() always a string on the wire, always a 400
69
87
  ```
70
88
 
71
- A controller that delegates:
89
+ A handler that delegates:
72
90
 
73
91
  ```
74
- ✅ findOne(@Param('id') id: string) { return this.users.findById(id) }
75
- ❌ findOne(@Param('id') id: string) {
76
- const u = await this.users.findById(id)
77
- if (u.plan === 'trial' && u.createdAt < cutoff) u.plan = 'expired'
78
- return u
92
+ ✅ async (request) => app.container.resolve(FindProfileUseCase).execute(currentUser(request).id)
93
+ ❌ async (request) => {
94
+ const profile = await app.container.resolve(FindProfileUseCase).execute(id)
95
+ if (profile.plan === 'trial' && profile.createdAt < cutoff) profile.plan = 'expired'
96
+ return profile
79
97
  }
80
98
  ```
81
99
 
82
100
  ## Enforcement
83
101
 
84
- **Boot.** The prefix, versioning, `ValidationPipe` and serializer are configured once at bootstrap, so
85
- a route cannot opt out of them by accident.
102
+ **Boot.** `src/infra/http/app.ts` refuses to start when a route under `/api/v` declares no response
103
+ schema, and names it. The compilers, the prefix and the documentation are registered once there, so a
104
+ route cannot opt out of them by accident.
86
105
 
87
- **Review only.** Resource naming, controller thinness, and (most importantly) whether a new
88
- sensitive column carries `@Exclude`. Nothing checks that, and a column added without it is published
89
- to every client of that route.
106
+ **Review only.** Resource naming, the thinness of a handler, `201` on `POST`, `z.strictObject` on input,
107
+ and (most importantly) whether a new sensitive column has stayed out of the output schema. The schema
108
+ lists what leaves, so a field is only published when somebody adds it to that list.
@@ -0,0 +1,130 @@
1
+ # Structure
2
+
3
+ ## Rule
4
+
5
+ Split `src/` into two layers. `src/domain` holds what the product is: entities, ports, use cases, DTOs and
6
+ errors. `src/infra` holds how it runs: configuration, auth, the database adapters, the HTTP edge and the
7
+ dependency container.
8
+
9
+ Import nothing from `src/infra`, from the ORM or from the HTTP framework inside `src/domain`. The domain
10
+ imports `zod` for its DTOs and `tsyringe` for its decorators, and nothing else outside itself, except by
11
+ name: an entity declaring a relation imports `Ref`, `ref` and `Collection` from `@mikro-orm/core`, and
12
+ nothing more.
13
+
14
+ Create one module per resource under `src/domain`: `users`, `orders`, `invoices`. A module holds only these
15
+ folders, each created with its first file:
16
+
17
+ | Folder | Holds | File |
18
+ |---|---|---|
19
+ | `entities/` | A plain class with the data and the behaviour of one entity | `<name>.entity.ts` |
20
+ | `repositories/` | The port: an interface and its `Symbol` token, in one file | `<name>.repository.ts` |
21
+ | `use-cases/` | One class per use case, with one `execute()` method | `<verb>-<noun>.use-case.ts` |
22
+ | `dto/` | Zod schemas and their inferred types, input and output | `<name>.dto.ts` |
23
+ | `errors/` | Classes extending `DomainError` | `<name>.error.ts` |
24
+ | `services/` | A pure function spanning several entities of the module | `<name>.service.ts` |
25
+
26
+ Put logic about one entity in a method of that entity. Put logic spanning several in an exported function
27
+ under `services/`. A pure function is imported directly and never registered in the container.
28
+
29
+ For every port, write its adapter under `src/infra` and register it in `src/infra/di/<module>.di.ts`, which
30
+ `src/infra/di/index.ts` calls.
31
+
32
+ Name every file with its role suffix, including where the folder already says it.
33
+
34
+ Reach another module only through its use cases: inject the use case class. A module's ports are its own;
35
+ no other module injects them.
36
+
37
+ Two modules never depend on each other in both directions, and neither do two use cases. When they appear
38
+ to, move the common part into a third module named for what it does. Do not use tsyringe's `delay()`.
39
+
40
+ Do not write barrel files. An `index.ts` that holds code of its own, as `src/infra/di/index.ts` does, is not
41
+ one.
42
+
43
+ Put in `src/domain/shared/` only a primitive every module uses or a second module has come to need, in a
44
+ folder named for its role: `errors/`, `transactions/`. A capability with a name of its own, such as
45
+ `notifications`, `pdf` or `storage`, is a module, not a shared file.
46
+
47
+ ## Rationale
48
+
49
+ The layers exist so the rules can be read and tested without a database or a server. A use case receives
50
+ its ports through the constructor, so its test hands it an in-memory fake; nothing about Postgres or
51
+ Fastify is needed to prove a rule of the product. The rule is kept by convention rather than by a checker,
52
+ a choice made knowingly: the one checker considered was measured once producing a useless file to satisfy
53
+ itself.
54
+
55
+ One class per use case keeps each constructor to what that case needs. A service per module accumulates
56
+ the dependencies of every case in one constructor and grows without a limit.
57
+
58
+ The suffix is always written because the same name lives in several layers. `update-profile` is both a
59
+ DTO and a use case; `profile` is an entity, a schema, a port and an adapter. Without the suffix, an editor
60
+ shows four tabs named `profile.ts` and a file search cannot tell them apart. A rule of *suffix where it
61
+ collides* would need judging at every new file and would rename old files when a new one arrived.
62
+
63
+ Reaching another module through its use cases means the module that owns an entity is the only one that
64
+ writes it, so its rules cannot be bypassed from outside. A cycle is refused because tsyringe's answer to
65
+ one, `delay()`, is presented in its own documentation as the way out of an `undefined constructor` error:
66
+ a patch over a design already in a loop. Barrel files are refused because they are how an invisible cycle
67
+ forms: a file imports the index that imports it back.
68
+
69
+ A folder exists only with its first file because Git does not keep an empty one, and a folder waiting to
70
+ be filled is filled for its own sake. `domain/shared/` has an admission test because a shared folder
71
+ without one only grows; requiring a role for each folder forces the question *what is this*.
72
+
73
+ **What this costs:** a module that is large and still one thing gets many files, one per use case. And
74
+ nothing but review keeps the domain free of the infrastructure.
75
+
76
+ ## Applies to
77
+
78
+ Every file under `src/` of the API. The rules about reaching across modules apply wherever one module's
79
+ code refers to another's.
80
+
81
+ ## Examples
82
+
83
+ The layers:
84
+
85
+ ```
86
+ ✅ src/domain/orders/use-cases/create-order.use-case.ts imports @/domain/…
87
+ ❌ src/domain/orders/use-cases/create-order.use-case.ts imports @mikro-orm/core
88
+ ```
89
+
90
+ A module:
91
+
92
+ ```
93
+ ✅ domain/users/entities/profile.entity.ts
94
+ domain/users/repositories/profile.repository.ts interface + PROFILE_REPOSITORY
95
+ domain/users/use-cases/update-profile.use-case.ts
96
+ domain/users/dto/update-profile.dto.ts
97
+ ❌ domain/users/users.service.ts every case in one class
98
+ ❌ domain/users/profile.ts no role suffix
99
+ ```
100
+
101
+ Reaching another module:
102
+
103
+ ```
104
+ ✅ constructor(private readonly findProfile: FindProfileUseCase) {}
105
+ ❌ constructor(@inject(PROFILE_REPOSITORY) private readonly profiles: ProfileRepository) {} from orders
106
+ ❌ constructor(@inject(delay(() => CreateOrderUseCase)) …)
107
+ ```
108
+
109
+ Where logic goes:
110
+
111
+ ```
112
+ ✅ profile.update(changes) one entity
113
+ ✅ domain/orders/services/order-total.service.ts several entities
114
+ ❌ a private function copied into two use cases
115
+ ```
116
+
117
+ Importing:
118
+
119
+ ```
120
+ ✅ import { FindProfileUseCase } from '@/domain/users/use-cases/find-profile.use-case'
121
+ ❌ import { FindProfileUseCase } from '@/domain/users'
122
+ ```
123
+
124
+ ## Enforcement
125
+
126
+ **Lint.** Biome's `noBarrelFile`, enabled in the API's `biome.jsonc`, refuses a file that re-exports.
127
+
128
+ **Review only.** Everything else. Nothing checks that the domain imports no infrastructure, that a module
129
+ maps to a resource, that another module is reached through its use cases, that no cycle has formed, or
130
+ that `domain/shared/` admits only primitives; a reviewer does.
@@ -38,9 +38,10 @@ API renames a field, the client keeps compiling against its own copy, and the fa
38
38
  `undefined` on a screen.
39
39
 
40
40
  **Importing the entity type from the API package looks like the obvious fix and is unsound.** Responses
41
- are entities passed through `ClassSerializerInterceptor`, so every `@Exclude`d property is missing from the
42
- JSON while remaining on the entity type. The shared type would promise `tenantId` on a payload that never
43
- carries it: a type that lies, which is worse than the duplication it removes.
41
+ pass through the route's output schema, which drops every field it does not list, so a property can remain
42
+ on the entity type while missing from the JSON; and a `Date` on the entity arrives as an ISO string. The
43
+ shared type would promise `tenantId` on a payload that never carries it: a type that lies, which is worse
44
+ than the duplication it removes.
44
45
 
45
46
  **The contract lives in one directory, not beside each feature, so that one template serves both
46
47
  architectures.** The monorepo form moves `api-contract/` whole into `packages/api-contract` and changes one
@@ -2,8 +2,9 @@
2
2
 
3
3
  ## Rule
4
4
 
5
- Name every source file in kebab-case, with the role suffix the generator uses: `users.service.ts`,
6
- `users.controller.ts`, `create-user.dto.ts`, `button.tsx`.
5
+ Name every source file in kebab-case. In `api`, add the role suffix, always: `profile.entity.ts`,
6
+ `update-profile.use-case.ts`, `update-profile.dto.ts`, `users.controller.ts`. A component is
7
+ `button.tsx`.
7
8
 
8
9
  Write a comment only when it carries a fact the code cannot carry about itself. Before writing one, ask
9
10
  whether a careful reader could recover it anyway. If they could, delete it.
@@ -19,9 +20,11 @@ tag or a change log; git holds all three.
19
20
 
20
21
  ## Rationale
21
22
 
22
- kebab-case is what `nest g resource` and shadcn's CLI both emit, so anything else adds a rename step after
23
- every generation, and a correction after generation is what people forget. It also never touches a problem
24
- PascalCase lives with: macOS and Windows are case-insensitive, so renaming `OrderList.tsx` to
23
+ kebab-case is what shadcn's CLI emits, so anything else adds a rename step after every generation, and a
24
+ correction after generation is what people forget. The suffix is always written because in `api` the same
25
+ name lives in several layers: `update-profile` is a DTO and a use case, `profile` an entity, a schema, a port
26
+ and an adapter, and without it an editor shows four tabs called `profile.ts`. kebab-case also never touches
27
+ a problem PascalCase lives with: macOS and Windows are case-insensitive, so renaming `OrderList.tsx` to
25
28
  `orderList.tsx` does not register as a change in git, and the file ends up cased differently in the
26
29
  repository and on a Linux clone.
27
30
 
@@ -44,8 +47,8 @@ Every source file, in every type.
44
47
  Naming:
45
48
 
46
49
  ```
47
- ✅ create-user.dto.ts users.service.ts order-list.tsx
48
- ❌ CreateUserDto.ts UsersService.ts OrderList.tsx
50
+ ✅ create-user.dto.ts create-user.use-case.ts order-list.tsx
51
+ ❌ CreateUserDto.ts create-user.ts OrderList.tsx
49
52
  ```
50
53
 
51
54
  A comment that earns its place: