A temporal EAV entity store in Postgres
Part 3 — Services and the API edge
Part 1 stored time; Part 2 turned the storage into a CRUD machine. This part is the layer where business meaning lives: services that own cross-entity transactions and talk to the outside world, and controllers that expose the whole thing — point-in-time reads included — as a plain REST API.
Goal
Keep the repository layer meaning-free and put every business rule in one place. A repository
knows how to close and open rows; it does not know that a product name must be unique in its
category, that creating a product must notify an external system, or which tenant is calling.
Services own those rules — and they own the transaction, composing several
repository calls (Part 2's tx rule) into one atomic commit. Controllers stay thin:
routing, validation, and translating HTTP into service calls.
Who owns what
| Layer | Owns | Must not know |
|---|---|---|
| Controller | Routes, DTO validation, guards, Swagger, cache headers — translating HTTP into one service call. | Transactions, storage shape, business rules. |
| Service | Business rules, cross-entity orchestration, transaction lifecycle (begin/commit/rollback), calls to external systems. | SQL, EAV mechanics, HTTP. |
| Repository (Part 2) | CRUD over the temporal EAV storage; joins whatever tx it is handed. |
Business meaning, external systems, who is calling. |
The transaction wrapper
Part 2 said every repository method accepts a tx. Here is the other half: who
creates it. A tiny TransactionalService wraps TypeORM's QueryRunner
in an object with exactly three capabilities — so services never touch the runner directly, and
the commit-only-what-you-opened rule from Part 2's caveats becomes structural.
TransactionalService — begin / commit / rollback, nothing else
export interface Transaction {
commit(): Promise<void>;
rollback(): Promise<void>;
getEntityManager(): EntityManager; // this is the tx repositories accept
}
@Injectable()
export class TransactionalService {
constructor(@InjectDataSource() protected dataSource: DataSource) {}
async begin(): Promise<Transaction> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
return new TransactionImpl(queryRunner); // commit/rollback also release()
}
// for simple cases: run a callback inside one managed transaction
async runInTransaction<T>(fn: (entityManager: EntityManager) => Promise<T>): Promise<T> {
return await this.dataSource.transaction((entityManager) => fn(entityManager));
}
}
The service pattern
Every write method follows the same skeleton — and it is itself tx-aware, so
services compose into each other's transactions exactly like repositories do.
ProductService.create — adopt-or-begin, check, write, sync, commit
@Injectable()
export class ProductService implements IProductService {
constructor(
@Inject(TRANSACTIONAL_SERVICE) private readonly transactionalService: TransactionalService,
@Inject(SM_PRODUCT_REPOSITORY) private readonly products: IProductRepository,
) {}
public async create(
customerId: string,
categoryUuid: string,
product: CreateProductDTO,
tx?: EntityManager,
): Promise<ProductView> {
// 1. adopt the caller's transaction, or begin one of our own
let transaction: Transaction | undefined;
if (!tx) {
transaction = await this.transactionalService.begin();
tx = transaction.getEntityManager();
}
try {
// 2. business rules — things no repository should know
const timestamp = new Date();
const existing = await this.products.getByNameInCategory(
customerId, categoryUuid, product.name, timestamp, tx);
if (existing) {
throw new ConflictException(`Product ${product.name} already exists`);
}
// 3. the write — repository joins our tx
const created = await this.products.create({ ...product, customerId }, tx);
// 4. external side effect BEFORE commit: if the sync fails,
// the local write rolls back and nothing half-exists
await syncProductToERP(customerId, categoryUuid, created);
// 5. commit only if we opened it
await transaction?.commit();
return created;
} catch (err) {
await transaction?.rollback();
throw err;
}
}
}
The interesting decision is step 4. Calling an external system inside the transaction boundary means a sync failure rolls the local write back — you never commit an entity the outside world doesn't know about. The price: the transaction (and its locks) stays open for the duration of an HTTP call, and the external system may still have accepted the data even though the local rollback happened. It's a trade, not a free lunch — the caveats below.
Batch upsert — one transaction for a whole tree
batchSave — upsert N entities with in-batch parent resolution
- Adopt-or-begin a transaction — the entire batch is atomic; a failure on item 40 of 50 rolls back all 50.
- For each incoming DTO, look up an existing entity by its natural key (name within the category) at one shared
timestamp. - Resolve in-batch parents: if a DTO references its parent by name and that parent was created earlier in this same batch, wire the fresh uuid — so a whole tree can be imported in one call, parents before children.
- Exists →
update(Part 2's temporal update, joining the tx); doesn't →createvia the service (which adopts the same tx). - After all items: one external sync for the whole batch, then commit. Any failure anywhere — including the sync — rolls back everything.
The validation ladder
Part 1's caveat said the storage cannot say "no". By this layer, four ladders of checks have said it instead — each one closest to the thing it protects:
| Rung | Where | Answers | On failure |
|---|---|---|---|
| DTO shape | Controller — ValidationPipe (class-validator), transform + whitelist |
Is this even the right shape? Unknown fields are stripped, wrong types rejected. | 400 before any service code runs. |
| Virtual schema | Repository — per-field validators from fieldsMeta (Parts 1–2) |
Is this value valid for this field? (non-empty name, positive price, parent exists…) | 422 inside the write transaction; everything rolls back. |
| Entity rules | Repository — entityValidators |
Do these fields make sense together? | Same 422 + rollback. |
| Business rules | Service — pre-write checks in the same tx | Is this allowed in the world? (uniqueness per category, tenant ownership…) | Domain HTTP errors — 409 Conflict, 404, 403 — before the write happens. |
The API edge
Controllers are deliberately boring — with one exception worth showing: time-travel is a
public query parameter. Every read endpoint takes an optional timestamp,
handed straight down to the point-in-time reads from Parts 1–2. "What did this tree look like
last quarter?" is just a query string.
ProductController — tenant-scoped routes, timestamp everywhere
@ApiTags('products')
@Controller('v1/customers/:customerId/products')
@UseInterceptors(ClassSerializerInterceptor)
export class ProductController {
constructor(
@Inject(SM_PRODUCT_SERVICE) private readonly products: IProductService,
) {}
@Get('/:productUuid')
@ApiQuery({ name: 'timestamp', type: String, required: false })
async getOne(
@Param('customerId') customerId: string,
@Param('productUuid', ParseUUIDPipe) productUuid: string,
@Query('timestamp') timestamp?: string,
): Promise<ProductView> {
const ts = timestamp ? new Date(timestamp) : new Date();
return this.products.findById(customerId, productUuid, ts);
}
@Post('/')
@UseGuards(EditPermissionsGuard)
async create(
@Param('customerId') customerId: string,
@Query('categoryUuid', ParseUUIDPipe) categoryUuid: string,
@Body(new ValidationPipe({ transform: true, whitelist: true }))
product: CreateProductDTO,
): Promise<ProductView> {
return this.products.create(customerId, categoryUuid, product);
}
}
Three edge conventions worth stealing: every route is tenant-scoped by construction
(customers/:customerId/… — the id flows down to the customer-binding table from
Part 1, so cross-tenant reads are structurally awkward rather than policed); controllers inject
the service interface by token, never the class; and expensive tree reads cache
with minute-normalized timestamps in the key — point-in-time semantics survive
caching because the timestamp is part of the identity.
Caveats
Same rule as Parts 1–2: each of these was paid for.
- External calls inside the transaction hold locks. The sync-before-commit pattern buys "never commit what the world doesn't know about" at the cost of a transaction open across an HTTP call. Set timeouts on the external call, or move to an outbox when volume grows — but decide, don't drift into it.
- Rollback doesn't un-ring the external bell. If the sync partially succeeded before failing, the local rollback leaves the external system ahead of you. Make sync endpoints idempotent (ours upserts by entity uuid) so a retry converges instead of duplicating.
- Check-then-act is still racy. The uniqueness pre-check and the write live in one transaction, but two concurrent transactions can both pass the check (Part 1's constraints caveat). The 409 catches users, not races — the serialization decision from Part 1's checklist is what catches races.
- Batch order matters. In-batch parent resolution by name requires parents to precede children in the payload; a child arriving first logs an error and creates a rootless entity. Validate order up front or topologically sort — silently tolerating it was a mistake we kept.
- Client-supplied timestamps are input. A public
timestampparameter is powerful and must be validated like anything else: reject unparseable dates, and decide whether reading the future (timestamp > now) is meaningful or a 400. - Time-aware caching needs time in the key. Caching a tree response without the timestamp in the key serves the wrong century to a time-travel query. Normalizing the timestamp (to the minute) keeps hit rates useful — and bounds staleness explicitly.
- Wrap external-sync failures with intent. A bare gateway error surfacing from step 4 reads like a storage bug. Re-wrap it ("sync failed after update: …") so on-call sees immediately that the local write rolled back because of the sync.
Before you ship: the decision checklist
Closing the series the same way: decisions, not defaults. Parts 1 and 2 decided storage and the class; these are the last ones before traffic.
- Who begins transactions. Services only, repositories only, or both? (my choice — services begin, repositories join; a repository begins one only when called without a
tx) - External side effects: inside or after the transaction. Sync-before-commit (consistent, lock-holding) vs commit-then-outbox (fast, eventually consistent). (my choice — sync-before-commit at current volume, idempotent sync endpoints as the safety net; outbox is the planned escape hatch)
- Duplicate handling. Pre-check with a domain 409, DB-level serialization, or upsert semantics? (my choice — pre-check + 409 for single writes, upsert-by-natural-key for batch imports; the race window is accepted and documented)
- Batch atomicity. All-or-nothing, or per-item results with partial success? (my choice — all-or-nothing: an import that half-succeeds costs more support time than a clean retry)
- Time-travel exposure. Internal-only, or a public
timestampparameter? (my choice — public on every read endpoint; it's the feature the storage was built for, and support lives in it) - Tenancy enforcement. Middleware/guard checks, or structural scoping in every route and query? (my choice — structural:
customers/:customerIdin the route, customerId flowing into every repository call; guards handle permissions, not scoping) - Caching semantics. Whether responses cache at all, and what makes the key. (my choice — opt-in per endpoint, short TTL, timestamp normalized to the minute inside the key)
- Where each validation rung lives. Fix the four-ladder split (DTO / virtual schema / entity / business) and refuse rules that try to live one rung down from where they belong. (my choice — the ladder above, enforced in review: a uniqueness rule in a field validator is a smell, not a convenience)
Three layers, three vocabularies: the storage speaks time, the class speaks CRUD, the service speaks business. The system stays understandable exactly as long as nobody borrows a neighbor's vocabulary.
Series
Part 1 — Storage concept: the six tables, the behavior matrix, the fetching SQL. Part 2 — The abstract repository: the class that turns storage into CRUD. Part 3 — this one — where the business logic and the API live. Part 4 — E2E testing: the suite that keeps all of it honest.