Data-access
The input is a shape already accepted: an operation, an entity or a capability whose behaviour is settled. This module does not re-open that decision. Its output is source architecture — which handle the constructor takes, which decorator names it, which file states the table name, which layer holds the transaction, and where a relation is asked for. The shape says what the system does; this pattern says where the code that does it lives and what it must look like.
Law
Persistence goes through an EntityManager, injected by a decorator that names which datasource it belongs to. There is no repository injection here, and no ambient default connection: both of those are handles that look identical whichever database they are pointed at, and this application has more than one.
The whole law follows from one property. An EntityManager is a unit of work that can be passed — handed to a helper, wrapped in a transaction, swapped for a transactional one — and a repository is not, because it is bound to one entity for its whole life. The moment a use case needs to write two tables atomically, code built on repositories has to be rewritten rather than extended, and the rewrite lands in whatever module noticed first.
The question that settles it: could this operation grow a second write? It nearly always can, and a handle that cannot carry the transaction across the pair is the wrong handle from the start.
This is binding, not advisory. Every constructor that touches persistence, every entity class and every multi-write operation carries exactly one situation code below, and there is no operation small enough to be exempt: a single-row read is DATA-1 for the same reason a settlement is. “It is only one table” is the most common place this rule gets skipped, because the second table arrives later and by then the handle is already wrong.
Situation codes
Every situation this module governs carries a code, DATA-<n>. The numbers are fixed and are cited from other law files and from task records; a code keeps its number and its meaning for as long as it exists.
| Code | Situation | What the source must look like |
|---|---|---|
DATA-1 | A handle is being injected into a constructor: which database does this touch? | An injected EntityManager whose parameter carries an @Inject*EntityManager() decorator naming the datasource. Forbidden: a bare EntityManager constructor parameter; relying on the framework’s default connection |
DATA-2 | The shape of the handle is being chosen | Persistence reached through the EntityManager. Forbidden: @InjectRepository(...); a Repository<T>, TreeRepository<T> or MongoRepository<T> parameter |
DATA-3 | A new entity is being declared, or an entity class is about to be renamed | @Entity("table_name"), or @Entity({ name: "table_name" }) when a schema qualifier is also needed. Forbidden: @Entity() with the table name left to be inferred from the class name |
DATA-4 | Several writes must live or die together | Work that must succeed or fail together running in one transaction, with the transactional manager passed to everything inside it. Forbidden: a helper reaching for its own injected manager while its caller is mid-transaction |
DATA-5 | What is this answer for, and who pays for it | Relations, selects and ordering stated at the call site that knows what the answer is for. Forbidden: eager relations on an entity |
DATA-1 AND DATA-2 READ THE SAME CONSTRUCTOR PARAMETER AND ARE NOT THE SAME FACT. DATA-1 is about a handle that does not say which database it points at; DATA-2 is about a handle that cannot grow a second write whichever database it points at. A perfectly decorated repository injection satisfies neither, and a bare EntityManager fails only the first. They are two codes because they fail independently and are fixed differently.
Reading an accepted shape
- Read what the shape states: which operation exists, which tables it touches, which answer it returns. This is settled and is not re-argued here.
- Read what the shape does not state, and therefore does not resolve: it never says which datasource the handle points at, which handle shape the constructor takes, what the table is called, which writes must be undone together, or which relations a call site needs. Those five gaps are what this module resolves, and nothing else is invented to fill them.
- Resolve outermost first: datasource before handle shape, handle shape before transaction, transaction before the relations a single call site asks for. An inner decision made on a wrong outer one is made twice.
- Ask each code’s question in order.
DATA-1: does the injection site say which database this is?DATA-2: can this handle carry a second write?DATA-3: does the entity name its own table?DATA-4: does everything inside the transaction receive the transactional manager as an argument?DATA-5: is each relation asked for by the call site that needs it? - When two codes both match, they both apply — they are not alternatives. A repository injection fails
DATA-1andDATA-2at once and is fixed by two different edits; a correctly injected manager used from inside somebody else’s transaction failsDATA-4while passingDATA-1cleanly. Emit one output block per situation, never one block covering both.
DATA-1 — the handle must say which database it points at
Situation. A constructor is being written for a service, a handler or a cron that touches data. The type EntityManager says nothing about the connection: the manager of the primary database and the manager of an analytics replica or a sandbox are the same type.
What it emits in source. A constructor parameter typed EntityManager whose parameter carries an @Inject*EntityManager() decorator naming the datasource. The house wrapper is one line: it binds the framework’s own injector to a named connection constant. The application holds more than one datasource family, which is the fact that makes an undecorated EntityManager ambiguous rather than merely untidy.
Recognition signs. A constructor parameter typed EntityManager with no decorator in front of it. Reading the file top to bottom finds not one word saying which database this is. The module wiring is correct, so the code runs — until the day someone changes the default provider.
Boundary. Not DATA-2: DATA-1 says the handle does not declare where it points; DATA-2 says the handle is the wrong shape even when it points in the right place. A carefully written @InjectRepository fails both; a bare EntityManager fails only the first. Not DATA-4 either: DATA-1 reads the injection site, DATA-4 reads the place of use — a correct injection can still be used wrongly by reaching for one’s own manager while the caller holds a transaction.
Common business situations. A new business service · a background cron · a projection fed from CDC · a seeder · a guard that must look up a permission · a queue-processing job · a service that cleans up temporary data.
DATA-2 — the handle must be able to carry a second write
Situation. The shape of the handle is being chosen. A repository looks more convenient: it already knows the entity, the calls are shorter, the IDE suggests better. But it is bound to one entity, so the day this operation must write one more table, it cannot travel with it.
What it emits in source. Persistence reached through the EntityManager, never through a repository — neither the @InjectRepository decorator nor the types Repository, TreeRepository or MongoRepository on a constructor parameter. Catching the type as well as the decorator matters, because the type alone is enough to bind the handle. One entityManager.transaction writing several tables through the callback’s manager is the shape this code produces; a repository-shaped handler could not have written it without one handle per table, and the tables would then commit separately.
Recognition signs. A constructor carrying @InjectRepository(...), or a parameter typed Repository<T> / TreeRepository<T> / MongoRepository<T>. A handler holding two or three repositories — one per table — with the writes sitting side by side and nothing wrapping them. The sentence “this does not need a transaction yet” appearing in code review.
Boundary. Not DATA-1: see above. Not DATA-4: DATA-2 says you have a unit of work that can be passed; DATA-4 says whether you actually passed it. Fixing DATA-2 does not make DATA-4 correct.
Common business situations. Enrolment plus a wallet debit · order creation plus an inventory lock · grading a submission plus awarding points · cancellation plus refund plus an audit log entry · granting an achievement plus sending a notification · anything whose business description contains the word “and”.
DATA-3 — the entity must name its own table
Situation. A new entity is being declared or — more dangerous — an entity class is being renamed to fit new business language. If the table name is left for the ORM to infer, it is inferred from the class name.
What it emits in source. @Entity("table_name") on the entity class, or @Entity({ name: "table_name", schema: "..." }) when a schema qualifier is also needed. The class name and the table name deliberately differ, which is the point: the class can be renamed without the table following it.
Recognition signs. @Entity() with no argument. A table name in a migration matching the class name exactly, suffixes like _entity included. A class-rename pull request whose diff contains no migration file.
Boundary. Not DATA-5: both are decisions that sit on the entity, but DATA-3 is about the table’s identity while DATA-5 is about the cost the entity imposes on every query. An entity that names its table perfectly can still make the whole system pay for an eager relation.
Common business situations. Adding a new entity · renaming a class to match business language · splitting one entity into two · merging two entities into one · an entity that must live in its own schema.
DATA-4 — the transaction is the unit of work, and it is passed, not assumed
Situation. A transaction is open. Inside it, a helper on another service is called to add points, write a log entry, emit a notification. That helper has its own manager, injected in its own constructor.
What it emits in source. Work that must succeed or fail together running in one transaction, with the transactional manager passed as an argument to everything inside it. The helper’s signature takes the work as (manager: EntityManager) => Promise<Result> and invokes it with the manager of the session that holds it; private methods on a service take manager as a parameter the same way. Nothing inside reaches for an injected manager.
Recognition signs. A call inside a transaction() callback that does not receive manager as an argument. A helper using this.entityManager while its caller is mid-transaction. A bug that appears only under load and always has the shape “half of it was written”.
Boundary. Not DATA-2: see above. Not DATA-1: a helper that violates DATA-4 usually does not violate DATA-1 at all — its manager is injected perfectly, it simply should not be used here. That is why this code cannot be caught by reading one file. No lint holds this code. Whether a helper was handed the caller’s transactional manager is a fact about the call graph, not about any one file; a rule reading a single file would have to guess, and a guess here fires on correct code — which is how a correct rule gets disabled.
Common business situations. Enrol then credit the wallet · grade then award XP · pay then grant an achievement · cancel then refund · every shared service called from inside somebody else’s transaction.
DATA-5 — the query states what it needs; the entity does not decide for it
Situation. Data is being fetched for one specific answer. Relations, columns and ordering are properties of that answer, not properties of the entity. The one who knows what is needed is the call site.
What it emits in source. Relations, selects and ordering stated at the call site that knows what the answer is for — a relations tree written in the handler, with the branch each screen needs named beside it. The entity’s @ManyToOne relations carry no eager option, so a caller that wants one column pays for one column.
Recognition signs. A relation declared eager on an entity. A screen that needs one column receiving the whole relation tree. Someone “optimising” by adding eager for convenience, and the query count rising somewhere else.
Boundary. Not DATA-3: see above. Not DATA-4: both are “a decision made in the wrong place”, but DATA-4 is a decision about atomicity while DATA-5 is a decision about cost. Getting DATA-4 wrong loses data; getting DATA-5 wrong degrades steadily. No lint holds this code. Whether a relation should have been asked for at the call site depends on what the answer is for, and nothing in the entity file says whether the caller needed one column or the whole tree.
Common business situations. A compact list beside a full detail page · a cart that needs price and translations · a leaderboard that needs only name and score · an export that needs a few columns · an admin screen that needs the whole tree.
Layer held
Which tier actually holds each code. unrepresentable means the wrong value cannot be written; enforced means a named rule from sources/be/data-access.mjs reports it; documented means nothing mechanical holds it and only a reader does.
| Code | Tier | Held by |
|---|---|---|
DATA-1 | enforced | starci-be/must-inject-entity-manager — reports a constructor parameter typed EntityManager that carries no decorator matching Inject*EntityManager. It reads the parameter and its parameter-property wrapper, so private readonly does not hide the decorator from it. |
DATA-2 | enforced | starci-be/no-injected-repository — reports both spellings: the @InjectRepository decorator, and the type Repository, TreeRepository or MongoRepository on a constructor parameter. Catching the type as well as the decorator matters, because the type alone is enough to bind the handle. |
DATA-3 | enforced | starci-be/require-entity-table-name — reports @Entity() whose arguments carry no string table name, directly or as the name property of the options object. |
DATA-4 | documented | Whether a helper was handed the caller’s transactional manager is a fact about the call graph, not about any one file. A rule reading a single file would have to guess, and a guess here fires on correct code — which is how a correct rule gets disabled. |
DATA-5 | documented | Whether a relation should have been asked for at the call site depends on what the answer is for. Nothing in the entity file says whether the caller needed one column or the whole tree. |
Two of five codes read documented, and that is the honest state rather than a gap to be papered over. The three that are enforced are exactly the three a parser can see in one file: a decorator on a parameter, a type on a parameter, and an argument to a decorator. The two that are not are the two that need either the call graph or the caller’s intent — and the module that holds the rules says so in its own header rather than shipping a heuristic that would be switched off within a week.
Anchor
A law that cannot be pointed at in real code is a proposal. Each code names a file in the reference repository and what to look for there.
| Code | Anchor | What to look for |
|---|---|---|
DATA-1 | src/modules/databases/postgresql/primary/primary.decorators.ts | The house wrapper is one line: it binds the framework’s own injector to a named connection constant. Beside it, src/modules/databases/ holds three datasource families — which is the fact that makes an undecorated EntityManager ambiguous rather than merely untidy. |
DATA-2 | src/features/api/core/graphql/mutations/courses/courses-checkout/courses-checkout.handler.ts | One entityManager.transaction writing several tables through the callback’s manager. A repository-shaped handler could not have written this without one handle per table, and the tables would then commit separately. Across src/, @InjectRepository and Repository<…> parameters occur zero times. |
DATA-3 | src/modules/databases/postgresql/primary/entities/cart-item.entity.ts | @Entity("cart_items") on a class named CartItemEntity. The two names deliberately differ, which is the point: the class can be renamed without the table following it. |
DATA-4 | src/features/api/core/graphql/mutations/courses/course-enroll/checkout-advisory-lock.ts | The helper’s signature takes the work as (manager: EntityManager) => Promise<Result> and invokes it with the manager of the session that holds the lock. Nothing inside reaches for an injected manager. src/modules/bussiness/achievements/achievements.service.ts shows the same shape on private methods: every one takes manager as a parameter. |
DATA-5 | src/features/api/core/graphql/queries/courses/my-cart/my-cart.handler.ts | The relations tree is written at the call site, with a comment naming which screen needs each branch. Then read cart-item.entity.ts again: its @ManyToOne relations carry no eager option, so a caller wanting one column pays for one column. |
Every code is anchored. Anchors are paths in the reference repository and exist for verification only.
Inputs
| Input | Evidence required |
|---|---|
| datasource | Which connection this work touches, and the decorator that names it |
| handle | The injected EntityManager, or the transactional manager received as a parameter |
| writes | Every table this operation writes, including the ones a helper writes on its behalf |
| atomicity | Which of those writes must succeed or fail together |
| helpers | Every function called inside the transaction, and where each one gets its manager |
| answer | What the caller does with the result, and therefore which relations and columns it needs |
Rules
- An injected
EntityManagernames its datasource at the injection site. - Persistence never arrives as a repository, by decorator or by type.
- An entity names its table; the table name is never a consequence of the class name. The options form is as valid as the string form.
- Work that must be undone together runs in one transaction.
- Everything inside a transaction receives the transactional manager as an argument.
- A relation is asked for by the call site that needs it, never granted by the entity to everyone.
- When it is not certain whether an operation will grow a second write, treat it as if it will. Choose the handle on that assumption, because changing the handle later costs far more than holding a handle wider than today’s need.
- Every persistence-touching constructor, entity and multi-write operation resolves to exactly one code per situation. No operation is out of scope.
Exceptions
Exceptions are part of the rule, not relief from it. Each is closed and names the code it applies to.
- The options form of
@Entity. UnderDATA-3,@Entity({ name: "t", schema: "s" })is equally valid and is not a style to discourage. It is the only form that can also carry a schema qualifier, so refusing it would push an author to delete the schema in order to satisfy the rule — a worse outcome than the inferred name the code exists to prevent. - A manager taken from an explicit query runner. Under
DATA-4, the transactional manager does not have to arrive from atransaction()callback. A helper that opens its own query runner to hold a session-scoped lock and then passes that runner’s manager inward is inside one unit of work and satisfies the code. What the rule forbids is a callee reaching for an injected manager, not a particular factory. This reading is inferred from the anchor rather than stated by the older flat law, and is recorded as a tension rather than assumed. DATA-4andDATA-5are read by a person. They are not softer than the other three; they are held by a different tier. A reviewer who cannot answer “where did this helper get its manager” has found the defect, not an ambiguity in the rule.- Adoption debt. All three rules measured at zero offenders in the reference repository and therefore ship at
error. A repository adopting them into an existing tree measures first and lands anything above zero atwarnwith the count beside it, burns it down, and flips toerrorat zero. Shipping aterrorwith debt outstanding blocks every commit that touches an offender, which is how a correct rule gets removed.
Output
One block per file the accepted shape produces.
datasource: <connection the decorator names>
handle: <injected EntityManager | transactional manager parameter>
writes: <every table this operation writes>
situation: <DATA-1 | DATA-2 | DATA-3 | DATA-4 | DATA-5>
placement: <where the decision must be stated: injection site, entity, transaction, call site>
reason: <the second write, or the caller whose cost this decision moves>Worked example
The accepted shape: checking out a cart enrols the buyer in every course in that cart and empties the cart, and the cart screen lists each item with its price.
The shape states the behaviour and the tables involved. It does not state which datasource holds them, which handle the handler takes, what the tables are called, whether the enrolment and the cart clearing must be undone together, or which relations the cart screen needs — so none of that is resolved by the shape, and each is resolved by a code below.
datasource: primary
handle: injected EntityManager
writes: none — constructor injection only
situation: DATA-1
placement: injection site — the checkout handler constructor
reason: the application holds three datasource families, so an undecorated EntityManager does not say which one this handler writes todatasource: primary
handle: injected EntityManager
writes: enrollments, cart_items
situation: DATA-2
placement: injection site — the checkout handler constructor
reason: not DATA-1, because the datasource is already named here; the fact that excludes it is that this handle must carry a second write, and a repository bound to enrollments cannot travel to cart_itemsdatasource: primary
handle: n/a — declaration site
writes: cart_items
situation: DATA-3
placement: entity — the cart item entity class
reason: not DATA-5, because nothing here is about query cost; the fact that excludes it is that the class may be renamed and the table must not follow, so @Entity("cart_items") states the identitydatasource: primary
handle: transactional manager parameter
writes: enrollments, cart_items
situation: DATA-4
placement: transaction — one entityManager.transaction whose callback manager is passed to every helper
reason: not DATA-2, because the handle is already an EntityManager; the fact that excludes it is that an enrolment helper called inside the transaction would otherwise use its own injected manager and commit independently, leaving the cart cleared and the enrolment gone on rollbackdatasource: primary
handle: injected EntityManager
writes: none — read path
situation: DATA-5
placement: call site — the cart query handler
reason: not DATA-3, because the table identity is already stated; the fact that excludes it is that only this screen knows it needs price and translations, so the relations tree belongs here and the entity grants no eager relation to everyoneScope
This rule holds for any relational back end in this stack that reaches persistence through a unit-of-work handle. It names no single feature: it is read the same way for a settlement, a cron and a single-row lookup. Examples are ordinary TypeScript in a Nest-shaped application; the Anchor table is the only place carrying repository paths, and it carries them as verification, not as illustration.
AN IDENTIFIER THAT SHIPS IS NOT A PRODUCT NAME IN THIS SENSE. A rule is cited by its published name, plugin prefix and all, because that is the exact string a build log prints and a disable comment carries. A citation that cannot be pasted into a search is not a citation. What the ban above forbids is PROSE and EXAMPLES that need a product to be understood — never an identifier somebody will read in a failure and have to look up.