@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,13 +2,16 @@
2
2
 
3
3
  ## Rule
4
4
 
5
- Run tests with Vitest, **except in `mobile`, which runs Jest with the `jest-expo` preset.** Put a test beside the
6
- file it tests, named `*.spec.ts`.
5
+ Run tests with Vitest, **except in `mobile`, which runs Jest with the `jest-expo` preset.** Put every test under
6
+ `test/`, at the path of the file it tests with `src/` replaced by `test/`, named `*.spec.ts`. Put what tests share
7
+ (setup, factories, fakes, a fake transport) under `test/support/`. Import code with `@/` and support with `@test/`,
8
+ never with a relative path.
7
9
 
8
- **Do not mock the database.** Test code that touches it against the real one. Write a test without a
9
- database only for code without a database.
10
+ **Do not mock the database.** In `api`, test a use case against an in-memory fake of its port, a class of
11
+ ours in `test/support/fakes/` that implements the port's interface. Test a repository adapter against the real
12
+ Postgres, and a route through `buildApp()` and `inject()`, also against the real Postgres.
10
13
 
11
- Do not mock `EntityManager`, a repository, or any concrete class from a library.
14
+ Do not mock `EntityManager` or any concrete class from a library.
12
15
 
13
16
  **In a client, replace the network through the injected transport**: pass a fake `fetch` to `createClient`.
14
17
  Do not add a request-mocking library. Test a component that shows an error by rendering it and asserting
@@ -20,19 +23,24 @@ Assert behaviour, not execution. A test that calls a function without asserting
20
23
 
21
24
  ## Rationale
22
25
 
23
- A mirrored test tree has to be moved when the code moves, and nobody moves it, so the old path survives
24
- as an orphan that still passes. Co-located, a test travels with its file without anyone thinking about it.
25
- `.spec.ts` follows the generator, so no rename step is needed after generating a module.
26
-
27
- **There are no interfaces to mock.** The architecture has no ports, so a service talks to MikroORM's
28
- concrete `EntityManager`. Mocking it means imitating identity map, unit of work and flush: the hardest
29
- thing in a suite to write and the least trustworthy. Each time the imitation is wrong, the test passes and
26
+ `src/` holds only what ships, so a folder of product code reads as the product, and everything that exists only
27
+ to test it lives in one tree with its setup and fakes beside it. Mirroring `src/` keeps finding a test mechanical:
28
+ the path is the file's own. The aliases keep an import independent of how deep a test sits, so moving a test never
29
+ breaks one.
30
+
31
+ **What it costs:** a mirrored tree has to move when the code moves. A test left behind still passes, as an orphan
32
+ whose path no longer matches any file, and nothing reports it. Moving a file means moving its test in the same
33
+ change.
34
+ **A fake of our port is not a mock of the database.** The port is an interface we wrote, the fake
35
+ implements it, and the compiler keeps the two in step; the use case under test has no idea a database
36
+ exists. Mocking `EntityManager` would mean imitating identity map, unit of work and flush: the hardest thing
37
+ in a suite to write and the least trustworthy. Each time that imitation is wrong, the test passes and
30
38
  production does not, and **a test that passes for the wrong reason is worse than no test, because it grants
31
- permission not to look.**
39
+ permission not to look.** So the SQL is proved where it lives, in the adapter, against the real database.
32
40
 
33
41
  A real Postgres is already provided, one per worker, with its schema built from the migrations. That also
34
- dissolves the *unit or integration* argument every project has and nobody wins: the answer is mechanical;
35
- the code touches the database or it does not.
42
+ dissolves the *unit or integration* argument every project has and nobody wins: the answer is mechanical. A
43
+ use case gets a fake, an adapter gets the database, a route gets both through the real application.
36
44
 
37
45
  A client has no database, and a `web` generated `alone` has no API beside it to test against. The transport is
38
46
  already a parameter (the integration layer takes it from the app) so a fake `fetch` needs no library and
@@ -68,32 +76,41 @@ factories) belongs to the database area, not here.
68
76
  Placement:
69
77
 
70
78
  ```
71
- ✅ features/orders/order-list.tsx
72
- features/orders/order-list.spec.tsx
73
- ❌ tests/features/orders/order-list.test.tsx
79
+ ✅ src/features/orders/order-list.tsx
80
+ test/features/orders/order-list.spec.tsx
81
+ ❌ src/features/orders/order-list.spec.tsx beside the file
82
+ ❌ test/order-list.spec.tsx not at the file's path
83
+
84
+ ✅ import { renderApp } from '@test/support/render-app'
85
+ ❌ import { renderApp } from '../../support/render-app'
74
86
  ```
75
87
 
76
- What a service test uses:
88
+ What a test of the API uses:
77
89
 
78
90
  ```
79
- ✅ const em = await testEntityManager() // real Postgres
91
+ ✅ new UpdateProfileUseCase(new InMemoryProfileRepository()) a use case: our fake
92
+ ✅ new MikroOrmProfileRepository(testOrm().em.fork()) an adapter: real Postgres
93
+ ❌ new UpdateProfileUseCase({ findByUserId: vi.fn(), save: vi.fn() })
80
94
  ❌ const em = { findOne: vi.fn(), flush: vi.fn() }
81
95
  ```
82
96
 
83
97
  Asserting:
84
98
 
85
99
  ```
86
- ✅ expect(await service.activate(id)).toMatchObject({ status: 'active' })
87
- ❌ await service.activate(id) // covers the line, checks nothing
100
+ ✅ expect(await activate.execute(id)).toMatchObject({ status: 'active' })
101
+ ❌ await activate.execute(id) // covers the line, checks nothing
88
102
  ```
89
103
 
90
104
  ## Enforcement
91
105
 
92
- **Structural.** With no mock of the database available as a convention, a service test has to use the real
93
- one.
106
+ **Compiler.** A fake implements the port's interface, so a port that changes breaks every fake that no
107
+ longer matches.
108
+
109
+ **Configuration.** Each runner collects only `test/**`. That also means a spec written beside its file under
110
+ `src/` is never run, and nothing says so.
94
111
 
95
- **Review only.** That a test asserts something, that a new module arrived with tests at all, and that
96
- nobody introduced a mock of a library class.
112
+ **Review only.** That a test asserts something, that a new module arrived with tests at all, that nobody
113
+ introduced a mock of a library class, that no spec sits under `src/`, and that a moved file took its test along.
97
114
 
98
115
  **The known gap:** with no threshold, coverage can fall and CI stays green. Review is the only defence, and
99
116
  review tires. If a floor is ever added, the honest form is on the diff (new code arrives tested) rather
@@ -2,8 +2,8 @@
2
2
 
3
3
  ## Rule
4
4
 
5
- Base the TypeScript configuration on `nestjs/typescript-starter`: `module` and `moduleResolution` both
6
- `nodenext`, `target` `ES2023`, `strict` on, `strictPropertyInitialization` off.
5
+ Configure TypeScript with `module` and `moduleResolution` both `nodenext`, `target` `ES2023`, `strict` on,
6
+ `strictPropertyInitialization` off.
7
7
 
8
8
  Add `noUncheckedIndexedAccess`. Do not add `exactOptionalPropertyTypes`.
9
9
 
@@ -13,13 +13,16 @@ modules.
13
13
  Run Biome's recommended preset, configured in `biome.jsonc`. Write a one-line reason in the configuration
14
14
  for any rule added or disabled.
15
15
 
16
- In `api`, enable `javascript.parser.unsafeParameterDecoratorsEnabled` and turn `style.useImportType` off.
16
+ In `api`, enable `experimentalDecorators` and `emitDecoratorMetadata`, enable Biome's
17
+ `javascript.parser.unsafeParameterDecoratorsEnabled`, turn `style.useImportType` off, and turn
18
+ `performance.noBarrelFile` on. Run TypeScript through SWC: `@swc-node/register` in development,
19
+ `unplugin-swc` in tests, `@swc/cli` for the build. Never `tsx`.
17
20
 
18
21
  In a client using shadcn, turn `a11y.noLabelWithoutControl` and `a11y.useSemanticElements` off for
19
22
  `components/ui/` only, through an override. Keep both on everywhere else.
20
23
 
21
- **Never import a class with `import type` where Nest reads its type at runtime**: a constructor
22
- dependency, or a DTO received through a decorated handler parameter.
24
+ **Never import a class with `import type` where tsyringe reads its type at runtime**: a constructor
25
+ dependency injected without `@inject`.
23
26
 
24
27
  Run `biome check --staged --write` in `.githooks/pre-commit`. Run `tsc --noEmit` in `.githooks/pre-push`.
25
28
  Wire both with `"prepare": "node .githooks/install.mjs"`, which sets `core.hooksPath` inside a Git repository,
@@ -27,13 +30,12 @@ does nothing outside one, and fails when Git is present and the setting cannot b
27
30
 
28
31
  ## Rationale
29
32
 
30
- Following Nest's starter is not deference: it is what keeps the generator and the convention in agreement,
31
- so a freshly generated project needs no correction afterwards. `nodenext` also happens to be one of the
32
- three values MikroORM v7 accepts, so the ORM and the framework ask for the same thing.
33
+ `nodenext` is one of the three values MikroORM v7 accepts, and it is what Node itself resolves.
33
34
 
34
- `strictPropertyInitialization` is off because a DTO's properties are filled by validation rather than by a
35
- constructor, so the flag reports an error about something correct. The cost is real and wider than DTOs:
36
- with it off, **any** class may declare a property that is never assigned and the compiler stays quiet.
35
+ `strictPropertyInitialization` is off because an entity's columns are filled by hydration or by the
36
+ database, not by its constructor, so the flag reports an error about something correct. The cost is real
37
+ and wider than entities: with it off, **any** class may declare a property that is never assigned and the
38
+ compiler stays quiet.
37
39
 
38
40
  `noUncheckedIndexedAccess` is added because `rows[0]` treated as present is the most common source of a
39
41
  runtime `undefined`. `exactOptionalPropertyTypes` is left out because it is the one strictness flag whose
@@ -48,17 +50,21 @@ the machine, which is what pre-push is.
48
50
  `biome.jsonc` rather than `biome.json`, because the reason has to live beside the rule and JSON cannot hold
49
51
  a comment.
50
52
 
51
- **Biome cannot parse Nest without the parser option.** Decorators on constructor and handler parameters
52
- (`@Inject()`, `@Body()`, `@CurrentUser()`) belong to the legacy proposal, and Biome reports every one as a
53
- syntax error, not a lint warning, until the option is on.
53
+ **Biome cannot parse the API without the parser option.** `@inject(TOKEN)` on a constructor parameter
54
+ belongs to the legacy decorator proposal, and Biome reports every one as a syntax error, not a lint warning,
55
+ until the option is on.
54
56
 
55
- **`useImportType` breaks injection, and nothing reports it.** Nest resolves a dependency from the type the
56
- compiler emits into the constructor's metadata. `import type` is erased from the output, so the emitted type
57
- becomes `Function` and the application refuses to boot. The rule's autofix makes that rewrite across the
58
- codebase in one pass; in this template it did, and lint, typecheck, build and every service test still
59
- passed. **On a DTO it is worse:** `ValidationPipe` receives `Object`, validates nothing, and raises nothing,
60
- so `whitelist` and `forbidNonWhitelisted` silently stop existing. Written by hand, the same `import type`
61
- does the same damage, which is why the rule forbids the import and not only the lint rule.
57
+ **`useImportType` breaks injection, and nothing reports it.** tsyringe resolves a dependency injected by
58
+ class from the type the compiler emits into the constructor's metadata. `import type` is erased from the
59
+ output, so the emitted type becomes `Object`, and resolving the class fails with *TypeInfo not known for
60
+ "Object"*. It type-checks, and because a use case is resolved per request, it fails on the first request to
61
+ that route rather than at boot. The rule's autofix would make that rewrite across the codebase in one pass,
62
+ and written by hand the same `import type` does the same damage, which is why the rule forbids the import and
63
+ not only the lint rule.
64
+
65
+ **SWC and never `tsx`** because tsyringe needs the decorator metadata, and esbuild, which `tsx` runs on, does
66
+ not emit it: a class injected by type fails in development only, while build and tests pass. SWC is the one
67
+ transformer that emits it in all three places, and `tsc` only checks types.
62
68
 
63
69
  **The shadcn override is scoped because both rules are right in application code.** A generated `Label`
64
70
  receives `htmlFor` through props the rule cannot see, and shadcn's `Field` uses `role="group"` deliberately.
@@ -87,13 +93,11 @@ Configuration changes:
87
93
  ❌ "noUnusedVariables": "off"
88
94
  ```
89
95
 
90
- Importing what Nest reads at runtime:
96
+ Importing what tsyringe reads at runtime:
91
97
 
92
98
  ```
93
- ✅ import { UsersService } from './users.service'
94
- import { UpdateProfileDto } from './dto/update-profile.dto'
95
- ❌ import type { UsersService } from './users.service' // boot fails: Function
96
- ❌ import type { UpdateProfileDto } from './dto/update-profile.dto' // nothing is validated
99
+ ✅ import { EntityManager } from '@mikro-orm/postgresql'
100
+ ❌ import type { EntityManager } from '@mikro-orm/postgresql' // TypeInfo not known for "Object"
97
101
  ```
98
102
 
99
103
  Reading an array:
@@ -110,9 +114,8 @@ Reading an array:
110
114
  **The hooks.** Formatting cannot reach a commit unformatted, and a type error cannot reach the remote,
111
115
  unless somebody uses `--no-verify`, or never ran an install and so never got `core.hooksPath` configured.
112
116
 
113
- **Boot, partially.** A constructor dependency imported as a type fails at boot. **A DTO imported as a type
114
- does not fail anywhere**, not at boot and not in a service test, so it is caught only by a request test
115
- that sends an invalid body, or by review.
117
+ **Route tests.** A constructor dependency imported as a type fails the first request that resolves it, so a
118
+ route test covering that use case catches it. A use case tested only through its fake does not.
116
119
 
117
120
  **Review only.** That a disabled rule carries its reason, and that `strictPropertyInitialization` being
118
- off did not leave an uninitialised property somewhere outside a DTO.
121
+ off did not leave an uninitialised property somewhere outside an entity.
@@ -2,96 +2,88 @@
2
2
 
3
3
  ## Rule
4
4
 
5
- Declare an entity with `defineEntity` **and a class**. Put the schema in the `defineEntity` call and
6
- no MikroORM decorators on the class. The class carries `@Exclude` and `@ApiProperty` only: the
7
- serialization and documentation decorators.
5
+ Write an entity as a plain class in `src/domain/<module>/entities/<name>.entity.ts`, with its data and its
6
+ behaviour. Map it in `src/infra/database/mikroorm/entities/<name>.schema.ts` with
7
+ `new EntitySchema({ class })`, and list the schema in `mikro-orm.factory.ts`.
8
8
 
9
- Keep the default `UnderscoreNamingStrategy`. Table names are singular and match the class name;
10
- columns are snake_case. Do not configure a naming strategy and do not set `tableName`.
9
+ Import nothing from the ORM in an entity, except `Ref`, `ref` and `Collection` from `@mikro-orm/core` for a
10
+ relation. Declare a to-one relation as `Ref<T>` and a to-many one as `Collection<T>`.
11
11
 
12
- Give every table `id`, `created_at` and `updated_at`. Let `id` be `uuid` with `DEFAULT uuidv7()`. Let
13
- both timestamps be `timestamptz`, maintained by MikroORM's `onCreate` and `onUpdate`.
12
+ Keep the default `UnderscoreNamingStrategy`. Table names are singular and match the class name; columns are
13
+ snake_case. Do not configure a naming strategy and do not set `tableName`.
14
+
15
+ Give every table `id`, `created_at` and `updated_at`. Let `id` be `uuid` with `defaultRaw: 'uuidv7()'`. Let
16
+ both timestamps be `datetime`, which is `timestamptz`, maintained by `onCreate` and `onUpdate`.
14
17
 
15
18
  Add `tenant_id` to every tenant-scoped table. Add `deleted_at` only with a stated reason.
16
19
 
17
- Map types as follows: money is `numeric`, text is `text`, JSON is `jsonb`, time is `timestamptz`, and
18
- an enum is `text` with a CHECK constraint. Never `float` for money, never `varchar(n)`, never `json`,
19
- never a native Postgres enum.
20
+ Map types as follows: money is `numeric`, text is `text`, JSON is `jsonb`, time is `datetime`, and an enum
21
+ is `text` with a CHECK constraint. Never `float` for money, never `string`, which is `varchar(255)`, never
22
+ `json`, never a native Postgres enum.
20
23
 
21
- Index `tenant_id`, every foreign key column, and every column used to fetch a single row. Add any
22
- other index on measurement, not on suspicion.
24
+ Index `tenant_id`, every foreign key column, and every column used to fetch a single row. Add any other
25
+ index on measurement, not on suspicion.
23
26
 
24
- Declare `ON DELETE RESTRICT` on every foreign key. Use `CASCADE` only where the child cannot exist
25
- without its parent, and say so where you use it.
27
+ Declare `ON DELETE RESTRICT` on every foreign key. Use `CASCADE` only where the child cannot exist without
28
+ its parent, and say so where you use it.
26
29
 
27
30
  ## Rationale
28
31
 
29
- The class exists because responses are serialized from the entity, which needs `@Exclude` to mark what
30
- must not leave. `defineEntity` exists because v7 made it the primary path and it keeps the ORM's own
31
- metadata off the class. The two are separate on purpose.
32
+ The class is the domain's, so a use case and its test work with it without a database. The schema is the
33
+ infrastructure's, so the ORM's metadata never reaches the domain. Hydration does not call the constructor,
34
+ so a class can require its essential fields in the constructor and still be loaded from a row.
35
+
36
+ `Ref` and `Collection` are the exception because they carry the one compile-time guarantee in the area: an
37
+ unpopulated relation has no `$` on its type, so an N+1 does not compile. They also let an aggregate, an order
38
+ and its items, be written by one flush. Nothing else from the ORM is worth that exception.
32
39
 
33
- snake_case is not a style choice: Postgres folds an unquoted identifier to lower case, so a camelCase
34
- column must be quoted in every hand-written query, psql session and dashboard, forever. Singular is the
35
- strategy's default, so it costs nothing and makes the table name match the class name.
40
+ snake_case is not a style choice: Postgres folds an unquoted identifier to lower case, so a camelCase column
41
+ must be quoted in every hand-written query, forever. Singular is the strategy's default.
36
42
 
37
- `numeric` because floating point does not represent 0.10 exactly and the error accumulates across a
38
- sum. `text` because it performs identically to `varchar(n)` in Postgres while adding no limit that
39
- hurts to change. `jsonb` because `json` stores raw text and cannot be indexed. A CHECK rather than a
40
- native enum because adding a value to a native enum is painless but removing or reordering one
41
- requires recreating the type and rewriting every dependent column.
43
+ `numeric` because floating point does not represent 0.10 exactly and the error accumulates across a sum.
44
+ `text` because it performs identically to `varchar(n)` in Postgres while adding no limit that hurts to
45
+ change. `jsonb` because `json` cannot be indexed. A CHECK rather than a native enum because removing or
46
+ reordering a native enum value requires recreating the type.
42
47
 
43
- The three mandatory indexes need no measurement, because they are consequences of the design.
44
- **Postgres does not index the referencing side of a foreign key** (only the referenced side, by virtue
45
- of being a primary key), so `order.customer_id` has no index unless someone declares it, and that is
46
- the most commonly forgotten index there is.
48
+ **Postgres does not index the referencing side of a foreign key**, so `order.customer_id` has no index
49
+ unless someone declares it: the most commonly forgotten index there is.
47
50
 
48
- `RESTRICT` matters because deletes are real here. `CASCADE` by default would remove a customer's
49
- orders, invoices and payments in one transaction and return success, with no error and no warning.
50
- `RESTRICT` makes that command fail and forces the decision into explicit code.
51
+ `RESTRICT` matters because deletes are real here. `CASCADE` by default would remove a customer's orders,
52
+ invoices and payments in one transaction and return success. `RESTRICT` makes that fail and forces the
53
+ decision into code.
51
54
 
52
55
  ## Applies to
53
56
 
54
- Every entity file, which lives inside its module's `entities/` directory.
57
+ Every file under `domain/<module>/entities/` and `infra/database/mikroorm/entities/`.
55
58
 
56
59
  ## Examples
57
60
 
58
- Naming:
59
-
60
- ```
61
- ✅ class User {} → table user, column created_at
62
- ❌ class User {} → table users, column createdAt
63
- ```
64
-
65
- Money and enums:
66
-
67
- ```
68
- ✅ amount: numeric status: text + CHECK (status IN (...))
69
- ❌ amount: double precision status: CREATE TYPE ... AS ENUM
70
61
  ```
62
+ ✅ class Profile { … } no ORM import
63
+ ❌ class Profile extends ProfileSchema.class the schema leaks into the domain
71
64
 
72
- Declaring text:
65
+ ✅ customer: Ref<Customer> items = new Collection<OrderItem>(this)
66
+ ❌ customer: Customer an unpopulated relation looks loaded
73
67
 
74
- ```
75
- ✅ displayName: p.text() → text
76
- ❌ displayName: p.string() → varchar(255)
77
- ```
68
+ ✅ displayName: { type: 'text' }
69
+ ❌ displayName: { type: 'string' } varchar(255)
78
70
 
79
- Deletes:
71
+ ✅ class User {} → table user, column created_at
72
+ ❌ class User {} → table users, column createdAt
80
73
 
81
- ```
82
74
  ✅ ON DELETE RESTRICT
83
- ❌ ON DELETE CASCADE // on customer → order
75
+ ❌ ON DELETE CASCADE on customer → order
84
76
  ```
85
77
 
86
78
  ## Enforcement
87
79
 
88
- **Migration review.** Every rule here shows up as SQL in a migration, which is the one place a human
89
- reads the schema as text. That makes migration review the real check, and the reason migrations are
90
- generated as readable SQL rather than applied invisibly.
80
+ **Compiler.** Reading `$` on an unpopulated relation does not typecheck.
81
+
82
+ **Migration review.** Every other rule here shows up as SQL in a migration, the one place a human reads the
83
+ schema as text.
91
84
 
92
- **Review only.** That a new table carries the structural columns, that a foreign key got its index, and
93
- that each `CASCADE` was deliberate.
85
+ **Review only.** That an entity imports nothing else from the ORM, that a new table carries the structural
86
+ columns, that a foreign key got its index, and that each `CASCADE` was deliberate.
94
87
 
95
- **A caveat worth knowing:** `user` is a reserved word in Postgres. `SELECT * FROM user` in psql returns
96
- the session user, not the table, and does not error. Quote it: `FROM "user"`. The ORM always quotes
97
- identifiers, so this only bites hand-written queries.
88
+ `user` is a reserved word in Postgres: `SELECT * FROM user` returns the session user and does not error.
89
+ Quote it in hand-written SQL.
@@ -2,78 +2,65 @@
2
2
 
3
3
  ## Rule
4
4
 
5
- Inject the `EntityManager`. Read with `em.find` and `em.findOne`, write with `em.persist` and
6
- `em.flush`. **Do not use `@InjectRepository`.**
5
+ Query only inside a repository adapter in `src/infra/database/mikroorm/repositories/`. It injects the
6
+ `EntityManager`; read with `em.find` and `em.findOne`, write with `em.persist` and `em.flush`. Do not use
7
+ MikroORM's `EntityRepository`: the port is ours.
7
8
 
8
- Declare every relation as `Ref<T>` or `Collection`. Access it only through `$`. State `populate` on
9
- every read that needs the relation.
9
+ Give a port one method per question the domain asks, named for it: `findByUserId`, not a generic `find`
10
+ taking a filter.
11
+
12
+ State `populate` on every read that needs a relation, and access a relation only through `$`.
10
13
 
11
14
  Reach for the QueryBuilder only where the operation does not exist in `em.find`: aggregation, window
12
- functions, CTEs, upserts. Reach for raw SQL only where the QueryBuilder cannot express it either.
13
- Write one comment line saying why, at each step down.
15
+ functions, CTEs, upserts. Reach for raw SQL only where the QueryBuilder cannot express it either. Write one
16
+ comment line saying why, at each step down.
14
17
 
15
18
  Apply the tenant rules to raw SQL exactly as to everything else.
16
19
 
17
- Return the managed entity from a service. **Only the services of the module that owns an entity mutate
18
- it.** A controller, an interceptor and a serializer read it and never write to it.
20
+ Return the managed entity from the repository. Only the use cases of the module that owns an entity change
21
+ it.
19
22
 
20
23
  ## Rationale
21
24
 
22
- MikroORM's own guidance is that the `EntityManager` is the persistence API and repositories are an
23
- extension point; `persist`, `flush` and `remove` were removed from repositories in v6 for being
24
- shortcuts to it. Since nothing can be persisted without the `em`, using a repository as well would put
25
- **two** doors in every service with a boundary somebody decides per method. This rule fights muscle
26
- memory (every Nest-with-an-ORM example reaches for `@InjectRepository`), which is why it is written
27
- negatively.
25
+ The adapter is the only file that knows the database, so a use case reads as the product's rule and its
26
+ test replaces the adapter with a fake. A method per question keeps the port honest: a generic filter would
27
+ leak the ORM's query language into the domain, and the fake would have to reimplement it.
28
+
29
+ `EntityRepository` is refused because it would be a second repository beside ours, with the same name and a
30
+ different contract.
28
31
 
29
- `Ref` and `$` turn N+1 from a review item into a compile error, because `Loaded<Entity, Hints>` tracks
30
- what was populated and accessing `$` on an unloaded relation does not typecheck. N+1 is the most silent
31
- database defect there is: the page works and merely gets slower as the list grows. Eager loading would
32
- trade it for over-fetching on every query, invisible at the call site.
32
+ `Ref` and `$` turn N+1 from a review item into a compile error, because `Loaded<Entity, Hints>` tracks what
33
+ was populated. N+1 is the most silent database defect there is: the page works and merely gets slower as the
34
+ list grows.
33
35
 
34
- The comment on a QueryBuilder query exists because a year later nobody can tell whether it is there
35
- because it was needed or because the author did not know how to express it otherwise, and that
36
- difference decides whether anyone may simplify it.
36
+ The comment on a lower-level query exists because a year later nobody can tell whether it is there because
37
+ it was needed or because the author did not know how to express it otherwise.
37
38
 
38
- An entity from `em.find` is managed, so changing it and flushing later writes to the database with no
39
- `persist` call. That is the Unit of Work, accepted deliberately. The ownership rule is what keeps a
40
- cosmetic change in an interceptor from becoming a write when some service downstream flushes.
39
+ An entity from `em.find` is managed, so changing it and flushing writes it with no `persist` call. That is
40
+ the Unit of Work, accepted deliberately. The ownership rule keeps a change made for display from becoming a
41
+ write when something downstream flushes.
41
42
 
42
43
  ## Applies to
43
44
 
44
- Every service. The relation rules also shape how entities are declared.
45
+ Every repository adapter and every port. The relation rules also shape how entities are declared.
45
46
 
46
47
  ## Examples
47
48
 
48
- The door:
49
-
50
- ```
51
- ✅ constructor(private readonly em: EntityManager) {}
52
- ❌ constructor(@InjectRepository(User) private readonly users: EntityRepository<User>) {}
53
49
  ```
50
+ ✅ findByUserId(userId: string): Promise<Profile | null>
51
+ ❌ find(filter: FilterQuery<Profile>) the ORM's language in the domain
54
52
 
55
- Relations:
53
+ ✅ em.findOne(Order, id, { populate: ['customer'] }) then order.customer.$.name
54
+ ❌ em.findOne(Order, id) then order.customer.$.name: does not compile
56
55
 
57
- ```
58
- ✅ const u = await em.findOne(User, id, { populate: ['organization'] })
59
- u.organization.$.name
60
- ❌ const u = await em.findOne(User, id)
61
- u.organization.name
62
- ```
63
-
64
- Dropping a level:
65
-
66
- ```
67
56
  ✅ // QueryBuilder: needs a window function, which em.find cannot express
68
- ❌ // (no comment)
57
+ ❌ (no comment)
69
58
  ```
70
59
 
71
60
  ## Enforcement
72
61
 
73
- **Compiler.** Accessing an unpopulated relation through `$` does not typecheck. This is the only
74
- compile-time guarantee in the area, and it covers the most expensive common defect.
62
+ **Compiler.** Reading `$` on an unpopulated relation does not typecheck.
75
63
 
76
- **Review only.** That `@InjectRepository` did not creep back in, that each step down the query ladder
77
- carries its reason, and that nothing outside the owning module's services mutates an entity. Nothing prevents the
78
- last one (a managed entity and a detached one have the same shape), and its failure is silent: a
64
+ **Review only.** That queries stay in the adapters, that each step down the query ladder carries its reason,
65
+ and that nothing outside the owning module's use cases changes an entity. The last one fails silently: a
79
66
  change made for display becomes a write on the next flush of that request.
@@ -2,67 +2,57 @@
2
2
 
3
3
  ## Rule
4
4
 
5
- Open a transaction only where more than one operation must succeed or fail together. A single
6
- `em.flush()` is already atomic.
5
+ Open a transaction only where more than one write must succeed or fail together. A single `save()` is
6
+ already atomic.
7
7
 
8
- Open it with `em.transactional(async em => …)`. Do not use the `@Transactional()` decorator, and never
9
- write `begin` / `commit` / `rollback` by hand.
8
+ Open it in the use case, through the `TransactionManager` port: `await this.transactions.run(async () => …)`.
9
+ Every repository called inside the callback joins the transaction. Never write `begin` / `commit` /
10
+ `rollback` by hand.
10
11
 
11
- Keep every network call outside the transaction block: an email, a payment gateway, any HTTP request.
12
+ Keep every network call outside the callback: an email, a payment gateway, any HTTP request.
12
13
 
13
- Leave the isolation level at READ COMMITTED. Where an invariant depends on reading a row and then
14
- deciding, lock it with `SELECT … FOR UPDATE`. Do not raise the isolation level.
14
+ Leave the isolation level at READ COMMITTED. Where an invariant depends on reading a row and then deciding,
15
+ lock it: give the port a method that says so, `findByIdForUpdate`, and implement it with
16
+ `lockMode: LockMode.PESSIMISTIC_WRITE`. Do not raise the isolation level.
15
17
 
16
18
  Do not retry a failed transaction. Fix the lock ordering instead.
17
19
 
18
20
  ## Rationale
19
21
 
20
- `em.flush()` wraps its changes in an implicit transaction, so an explicit one is not for writing
21
- correctly; it is for the case where two units of work must happen together. A per-request transaction
22
- would last as long as the request, so a five-second timeout at an email provider becomes five seconds of
23
- row locks; under concurrency that becomes a queue and the queue becomes an outage. The database ends up
24
- paying for someone else's slowness.
22
+ The boundary is the use case's decision, because only the use case knows which writes belong together; the
23
+ port keeps that decision in the domain without importing the ORM. The adapter uses `em.transactional`, and
24
+ inside it the global `EntityManager` resolves to the transaction's fork, which is why repositories need
25
+ nothing passed to them.
25
26
 
26
- The callback rather than the decorator, because the decorator makes the whole method the boundary and
27
- cannot be narrowed. A method that writes two rows and also sends an email would sit entirely inside the
28
- transaction with nothing in the code showing it. The decorator composes better through `REQUIRED`
29
- propagation; that is given up because its failure mode is silent and only appears under load.
27
+ A per-request transaction would last as long as the request, so a five-second timeout at an email provider
28
+ becomes five seconds of row locks; under concurrency that becomes a queue and the queue an outage.
30
29
 
31
30
  READ COMMITTED does not prevent read-decide-write: two requests read the same balance, both find it
32
- sufficient, and both write. Nothing fails. `FOR UPDATE` is the local, visible fix: the second
33
- transaction waits instead of aborting, so it needs no retry. Raising the isolation level would make
34
- every transaction in the system need retry logic, including the ones that never conflicted.
31
+ sufficient, and both write. Nothing fails. `FOR UPDATE` is the local, visible fix: the second transaction
32
+ waits instead of aborting, so it needs no retry. The lock is a named port method so the use case shows it.
35
33
 
36
- A deadlock is an ordering bug, not bad luck, and retrying hides it: the system slows under load and
37
- nothing ever fails loudly enough to be investigated. A retry also repeats whatever side effects already
34
+ A deadlock is an ordering bug, and retrying hides it. A retry also repeats whatever side effects already
38
35
  happened outside the database.
39
36
 
40
37
  ## Applies to
41
38
 
42
- Every service method that writes more than once.
39
+ Every use case that writes more than once, and `MikroOrmTransactionManager`.
43
40
 
44
41
  ## Examples
45
42
 
46
- Keeping the network out:
47
-
48
- ```
49
- ✅ await em.transactional(async (em) => { … })
50
- await mailer.send(…)
51
- ❌ await em.transactional(async (em) => { … ; await mailer.send(…) })
52
43
  ```
44
+ ✅ await this.transactions.run(async () => { … })
45
+ await this.mailer.send(…)
46
+ ❌ await this.transactions.run(async () => { … ; await this.mailer.send(…) })
53
47
 
54
- Read, decide, write:
55
-
56
- ```
57
- ✅ const acc = await em.findOne(Account, id, { lockMode: LockMode.PESSIMISTIC_WRITE })
58
- ❌ const acc = await em.findOne(Account, id) // two requests both pass the check
48
+ ✅ const account = await this.accounts.findByIdForUpdate(id)
49
+ ❌ const account = await this.accounts.findById(id) two requests both pass the check
59
50
  ```
60
51
 
61
52
  ## Enforcement
62
53
 
63
- **Review only, and one line of it matters more than anything else in this area:** that a read-decide-write
64
- sequence locks the row. Nothing detects a missing `FOR UPDATE`. The failure is silent and expensive
65
- (a negative balance, stock sold twice), and no transaction errors when it happens.
54
+ **Tests.** The template proves that a failed write inside `run()` rolls back the one before it.
66
55
 
67
- Also review: that no network call sits inside a transaction block, and that nobody added a retry
68
- wrapper.
56
+ **Review only, and one line of it matters more than anything else in this area:** that a read-decide-write
57
+ sequence locks the row. Nothing detects a missing lock, and no transaction errors when a balance goes
58
+ negative. Also review that no network call sits inside `run()` and that nobody added a retry.