When to use
Reach for this when you're writing the body of a handler/service/query/event/test and want the
team's implementation conventions — readability, async, nullability, SQL performance, messaging,
logging, and test shape. For where files go and what they're named, use the companion skill
structure-a-backend-service.
Each rule: portable principle → ▸ Example (TS/NestJS) (neutral listing domain;
<Entity>/<feature> = rename) → ▸ Other stacks. Some rules restate global policy; the global
rule stays authoritative (see Related).
Steps
1. Control flow — return early, no manual loops
- Return (or throw) early; don't nest. Handle the invalid/empty case first and bail, so the
happy path stays unindented.
if (minPrice == null && maxPrice == null) return; // guard
if (!items?.length) return null;
// ...happy path, never more than ~2 levels deep
- Transform collections with pipeline functions, not
for/while. map/filter/reduce/
find/some/every/flatMap. Keep callbacks pure (no mutating the source).const coverIds = media.filter((m) => m.type === MediaType.COVER).map((m) => m.id);
const labelByCode = categories.reduce((acc, c) => { acc[c.code] = c.label; return acc; }, {} as Record<string, string>);
- Imperative iteration only for: side effects (
forEach / for...of to mutate external state,
dispatch, log) and sequential async that must be ordered (for...of with await, e.g. a
retry loop). Never forEach with an async callback. ▸ Other stacks: comprehensions /
streams / map equivalents; the principle (declarative transform, imperative only for effects &
ordered async) is universal. (Matches global Code Style — Iteration & Collections.)
2. Parallelize independent async with Promise.all
Independent awaits run together; only await in sequence when one result feeds the next.
const [detail, owner] = await Promise.all([getDetail(id), getOwner(id)]); // independent → parallel
const files = await Promise.all(media.map(async (m) => ({ name: m.name, data: await toBase64(m) })));
A sequential await inside a map/loop for independent work is the anti-pattern — it serializes
needlessly. ▸ Other stacks: asyncio.gather, goroutines + errgroup, CompletableFuture.allOf.
Cheaper than parallelizing: don't run what a branch discards. If some flag/condition zeroes out
or ignores a result, compute that condition FIRST and skip the calls entirely (precedent:
listings-api#719 — an estimate engine ran 2 queries for non-tillable farms whose prices were then
forced to 0). And "moved/ported verbatim" is not an exemption — code you relocate must pass this
section even if its old home didn't.
3. Prefer null over undefined
Use an explicit nullable type for "absent" values; reserve undefined for "not provided". This
keeps DB-nullable columns, DTO fields, and return types consistent.
@Column({ nullable: true }) publishedAt!: Nullable<Date>; // entity field
async getFile(req: FileReq): Promise<Nullable<FileDto>> { ... } // return type
▸ Other stacks: Optional<T> (Java), *T/sql.NullString (Go), T | None (Python). The point:
one agreed "absent" value, not a mix of null and undefined. (TS note: ==/!= against null
intentionally matches both null and undefined.)
API response defaults: an absent array is [] (never null/undefined); an absent
scalar/object is null (never undefined). undefined disappears from JSON and breaks clients
that expect the key. ▸ Other stacks: same — empty collection for lists, explicit null for the rest.
4. Put private helpers below the public API
A class/file reads top-down: public methods first, then private helpers underneath. Helper names
are verb-led + single responsibility (fetchOwnerProfile, shouldTriggerReview, buildPayload).
@Injectable()
export class ListingService {
async createListing(dto: CreateListingDto) { /* ...delegates to helpers... */ }
async getListing(id: string) { ... }
// ── private helpers below ──────────────────────────────
private async fetchOwnerProfile(listing: Listing) { ... }
private shouldTriggerReview(listing: Listing): boolean { ... }
}
Keep methods to one screenful; extract a focused private method when one covers 3+ concerns.
(Matches global Code Style — Function Size & Density.)
5. Querying & performance
The repository/query builder is where most performance is won or lost. Apply all of these:
- Select only the columns you need — never implicit
SELECT *..select(['listing.id', 'listing.status', 'listing.price', 'photo']) // explicit, includes joined alias
- Push every filter into the query (status/type/date/soft-delete) and batch multi-value
filters with
IN (:...ids) — never fetch broadly then .filter() in code, and never query
per-item in a loop (that's the N+1). (Matches global Database & Migration Rules.).where('listing.isDeleted = false')
.andWhere('listing.status IN (:...statuses)', { statuses }) // one query, not one-per-status
- Load relations in the query, not lazily per row, to avoid N+1:
leftJoinAndSelect for
optional relations you need in the result, innerJoin for a required relation you only filter on
(no select), Brackets for grouped OR..leftJoinAndSelect('listing.photos', 'photo', 'photo.isDeleted = false') // eager, optional
.innerJoin('listing.owner', 'owner') // required, filter-only
- One round-trip when you can — fetch rows + total together (
getManyAndCount) instead of two
queries; combine related work rather than looping queries.const [rows, total] = await qb.take(take).skip(skip).orderBy(column, direction).getManyAndCount();
- Write-or-update in one statement with upsert instead of select-then-insert/update; it also
lets you batch insert + update + soft-delete together.
await this.photoRepo.upsert(
[...newPhotos, ...unused.map((p) => ({ ...p, isDeleted: true }))],
['id'], // conflict key
);
- Index for your filters + sort, and order deterministically. Add a composite
@Index matching
the common WHERE + ORDER BY; drive orderBy from a typed sort-map constant (not free-text), so
only indexed columns are sortable.@Index('listing_status_createdAt_idx', ['status', 'createdAt'])
// sort map: enum → { column, direction } ; query: const { column, direction } = listingSortMap[sortBy];
- Money & dates go through standard libraries, never ad-hoc math. Precise/decimal numeric work
uses a decimal library (floats drift —
0.1 + 0.2 !== 0.3); date/time uses a date library
(parsing, timezones, formatting). ▸ Example: a Decimal type + a decimal(p,s) column
transformer for money; a day/date lib for every date operation. ▸ Other stacks: your ecosystem's
decimal + datetime libs — never hand-roll currency math or timezone arithmetic.
▸ Other stacks: the same SQL discipline applies through any ORM/query layer (Django
select_related/prefetch_related, JPA fetch joins, sqlc/sqlx); upsert = INSERT ... ON CONFLICT / MERGE.
6. Events & async messaging (SQS/pub-sub)
- One event per file under the feature's
events/, holding the event class (extends a shared
BaseEvent<T>) and its handler; a barrel exports the handler list for batch registration.
- Emit from the use-case via the bus; never publish externally inline. The command/query handler
publishes a domain event; an event handler does the outbound I/O.
// in a command handler
this.eventBus.publish(new ListingStatusChangedEvent(listings));
// events/listing-status-changed.event.ts
@EventsHandler(ListingStatusChangedEvent)
export class ListingStatusChangedEventHandler implements IEventHandler<ListingStatusChangedEvent> {
async handle(event: ListingStatusChangedEvent): Promise<void> {
await this.publisher.dispatch({
event: TOPIC.LISTING_STATUS_CHANGED,
payload: event.data.map((l) => ({ listingId: l.id, status: l.status })), // mapped subset, NOT the raw entity
});
}
}
- Outbound payloads are an explicit mapped subset (id + the few fields subscribers need), never
the whole entity — that's a contract you don't want leaking internal columns.
- Inbound consumers are wired from config + named constants, not hard-coded URLs/strings. Queue
URLs are built from a centralized config block; topic/queue/pattern names are constants.
SqsModule.registerAsync({
useFactory: (config: ConfigService) => {
const { region, accountNumber, sqsEndpoint } = config.get(configEvents);
const consumers = [getQueueName(EVENT_PATTERNS.listing.published)].map((name) => ({
name, queueUrl: `${sqsEndpoint}/${accountNumber}/${name}`, region,
}));
return { consumers };
},
inject: [ConfigService],
});
// @SqsMessageHandler(queueName, false) async handleMessage(message: Message) { ... }
- In a queue (SQS) consumer, never throw — log and return. An unhandled throw returns the
message to the queue, where it's re-consumed until it expires — a poison message that can loop
forever. ▸ Example: the handler extends a shared
AbstractEventHandler; on failure it calls
logger.error(...) then return, so the message is acked/deleted while the error still reaches
alarms. Handle partial-batch failures explicitly (report which records failed). ▸ Other stacks:
ack/commit the message and route the failure to a dead-letter queue — don't let an exception
trigger blind redelivery.
▸ Other stacks: an in-process event bus or outbox emits; a separate consumer/worker handles the
queue. Principle: use-case → event → handler → broker, payloads are explicit, names are config.
7. Logging
- Structured metadata, not string concatenation. A short message string + a context object.
this.logger.info(`Response from <system> for "${path}"`, { data: maskPii(data), payload: maskPii(payload) });
this.logger.error("Can't publish listing", { listingId, resError: (error as Error).message });
- Mask PII/secrets before logging (identity numbers, tokens) — pass values through a masker.
- Levels:
info = normal milestones (external call made, status transition), warn = recovered/
fallback paths, error = exceptions & failed external calls (include the error message + ids),
debug = detailed tracing. Log every critical branch, fallback, and missing-config path (global
Feature Flags & Observability). Request logging + a global exception filter are wired once
centrally, not per handler. ▸ Other stacks: any structured logger (zap, structlog, SLF4J + MDC) —
key-value context, not interpolated strings.
8. Writing a test (integration layer)
These are integration tests — they boot the app and hit the real transport/DB boundary. For the
isolated unit-test layer (mocked deps, no DB — fast tests for handlers/services/DTOs) see
write-unit-tests; a repo may run one layer or both (two jest projects).
See structure-a-backend-service step 8 for the harness. Per spec:
describe('ListingCmdController', () => {
let client: ClientProxy, app: INestApplication, dataSource: DataSource;
let factoryCtx: FactoryContext, listingFactory: ListingFactory, listingRepo: Repository<Listing>;
beforeAll(() => {
({ client, app, dataSource } = global.testContext); // app booted once in the shared setup
listingRepo = dataSource.getRepository(Listing);
});
beforeEach(() => {
factoryCtx = new FactoryContext(dataSource);
listingFactory = new ListingFactory(factoryCtx); // fresh factory per test
});
afterEach(async () => {
jest.restoreAllMocks();
await listingRepo.delete({});
await factoryCtx.destroy(); // clean state between tests
});
it('publishes the listing and persists it', async () => {
// Arrange — seed via factory, mock only externals
const listing = await listingFactory.build({ status: ListingStatus.DRAFT });
jest.spyOn(SearchIndexService.prototype, 'index').mockResolvedValue({ ok: true });
// Act — exactly one action under test, blank line before & after
const { success } = await lastValueFrom(client.send(PATTERN.listing.publish, msg({ id: listing.id })));
// Assert — response AND persisted state, with specific expected values
expect(success).toBe(true);
const updated = await listingRepo.findOneOrFail({ where: { id: listing.id } });
expect(updated.status).toBe(ListingStatus.PUBLISHED); // not toBeTruthy()
});
it('fails for an unknown listing', async () => {
const { success } = await lastValueFrom(client.send(PATTERN.listing.publish, msg({ id: v4() })));
expect(success).toBe(false); // cover the error path too
});
});
- Structure every test Arrange → Act → Assert, blank line around the Act, exactly one Act
(one action under test) — the comments above show the shape.
- Seed with factories + a faker lib for inputs (realistic random names/emails/numbers/ids),
never hand-rolled literals; build only the fields the test asserts on. Clean up the rows created
in this test afterward (and only those) so parallel specs don't collide.
- Mock only what crosses the process boundary (external services via
spyOn(...prototype),
outbound HTTP via an intercept lib). Use the real DB; assert the persisted row, not just the reply.
- Assert specific expected values, not just truthiness —
expect(id).toBe(realId), not
toBeTruthy(). Common matchers: toBe (string/number), toMatchObject (part of an object),
toEqual (whole object/array), arrayContaining (array members).
- Group branch variations with
it.each instead of copy-pasting near-identical tests.
- Cover the success path + the error/edge path; assert outbound side effects with spy-called-with.
- Keep specs small and run coverage. One spec per API when it has many cases (≤ ~300 lines);
group several small APIs into one spec otherwise. Run the coverage report over what you wrote.
- Don't write isolated unit tests that bypass the boundary — if it can't be reached through the
boundary, skip it (global HTTP-layer testing rule).
9. Robustness — transactions, event-handler safety, external clients
- Multi-repo writes go in one transaction; broadcast a compensating event before re-throwing. Wrap
related mutations in
dataSource.transaction(em => …) and pass em to each repo op for atomicity.
In an event-driven flow, if the transaction fails after side effects were signalled, emit a
compensating event (e.g. OrderCanceled releasing held inventory) before throwing, so
downstream can undo:await dataSource.transaction(async (em) => {
const order = await saveOrder(em, dto);
try { await reserveInventory(em, order); await debitWallet(em, order); return order; }
catch (e) { await events.publish(new OrderCanceledEvent(order)); throw e; } // compensate, then roll back
});
- In-process event handlers must not crash the bus. A CQRS
@EventsHandler is fire-and-forget;
one failing handler (missing template, third-party down) shouldn't break sibling handlers. Wrap
handle so it logs + swallows instead of throwing — e.g. a shared @CatchException() decorator
(log + return). (This is the in-process twin of the SQS "don't throw" rule in §6.)
- External/microservice clients own their lifecycle. A
ClientProxy (TCP/microservice client)
closes on OnApplicationShutdown (no zombie connections on deploy); retry config (attempts +
delay) is env-driven (exponential backoff + jitter, capped); route every call through one base
send() wrapper that centralizes retries + error mapping (don't scatter clientProxy.send across services).@Injectable() export class WalletClient implements OnApplicationShutdown {
constructor(@Inject(walletMs) private readonly proxy: ClientProxy) {}
onApplicationShutdown() { this.proxy.close(); }
send<I, R>(pattern: string, data: I) { return this.base.sendAsync<I, R>(this.proxy, pattern, data); }
}
- Scope every read/write to the caller's tenant/permitted set. For non-admin roles, an
interceptor auto-injects the caller's allowed scope (e.g. their
locationIds/orgId) into the
query — or intersects a requested scope with the allowed set and drops the rest — so a handler
can't return or mutate another tenant's data even when asked. Centralize this in one
interceptor/guard; don't re-check in every handler. ▸ Other stacks: a query-scoping middleware or
DB row-level security.
- Bulk CSV/spreadsheet import (streaming parse + per-row error report + chunked upsert) has its
own skill — see
import-data-from-csv.
- Background jobs & caching (Bull queues, Redis cache) have their own skill —
see
background-jobs-and-caching.
▸ Other stacks: a DB transaction + outbox/compensation; a global error-swallowing wrapper on async
event handlers; one connection-managed client per dependency with retries centralized.
Verification
Reviewing a change, confirm:
- Control flow: no manual index
for/while for transforms (grep -nE 'for ?\(' src --include='*.ts'
should hit only side-effect/ordered-async spots); guard clauses up top; ≤2 nesting levels.
- Async: independent awaits are inside a
Promise.all; no await inside a .map used for
independent work.
- Nullability: nullable fields/returns use the nullable type, not
undefined.
- Layout of logic: private helpers sit below public methods; helper names are verb-led.
- Queries: every list/detail query has an explicit
.select([...]), filters are in the SQL
(IN (:...) for lists), relations are joined (no per-item query in a loop), pagination uses one
count+rows call, writes that can conflict use upsert, and sorted/filtered columns are indexed.
- Events: outbound payload is a mapped subset (not the raw entity); topic/queue names are
constants; consumers wired from config.
- Logging: message + context-object form (no string-concatenated context); PII masked; errors
log the cause + ids.
- Tests: go through the transport/HTTP boundary, seed via factories, assert persisted state, mock
only externals, cover an error path.
- Gates: lint, build, and the test suite pass (e.g.
pnpm lint / pnpm build / pnpm test).
Related
- structure-a-backend-service — where these files live and how a
feature is wired (this skill is the how to write the code inside; that one is the where & what).
- README — how project skills are organized.
- Global assistant instructions (the authority for the do/don't this skill restates): Code
Style — Iteration & Collections, Code Style — Function Size & Density, Database & Migration
Rules, DRY — Parallel flows, Feature Flags & Observability, Testing Rules / HTTP-layer
testing rule, Root-Cause First.
1---2name: write-service-code3description: Use when writing OR refactoring/moving/porting code inside a feature — a handler, query, service, event, or integration test. Consolidating or extracting existing code counts (moved-verbatim code must still pass the checklist — precedent listings-api#719 where sequential awaits survived a controller→service move). Control flow, async (Promise.all), query performance (N+1, upsert, joins, indexes), events/SQS, logging, decimal/date libs. Language-agnostic with TS/NestJS examples.4---56## When to use78Reach for this when you're writing the **body** of a handler/service/query/event/test and want the9team's implementation conventions — readability, async, nullability, SQL performance, messaging,10logging, and test shape. For *where files go and what they're named*, use the companion skill11[structure-a-backend-service](./structure-a-backend-service.md).1213Each rule: **portable principle** → **▸ Example (TS/NestJS)** (neutral `listing` domain;14`<Entity>`/`<feature>` = rename) → **▸ Other stacks**. Some rules restate global policy; the global15rule stays authoritative (see Related).1617## Steps1819### 1. Control flow — return early, no manual loops2021- **Return (or throw) early; don't nest.** Handle the invalid/empty case first and bail, so the22 happy path stays unindented.23 ```ts24 if (minPrice == null && maxPrice == null) return; // guard25 if (!items?.length) return null;26 // ...happy path, never more than ~2 levels deep27 ```28- **Transform collections with pipeline functions, not `for`/`while`.** `map`/`filter`/`reduce`/29 `find`/`some`/`every`/`flatMap`. Keep callbacks pure (no mutating the source).30 ```ts31 const coverIds = media.filter((m) => m.type === MediaType.COVER).map((m) => m.id);32 const labelByCode = categories.reduce((acc, c) => { acc[c.code] = c.label; return acc; }, {} as Record<string, string>);33 ```34- **Imperative iteration only for:** side effects (`forEach` / `for...of` to mutate external state,35 dispatch, log) and **sequential async that must be ordered** (`for...of` with `await`, e.g. a36 retry loop). Never `forEach` with an `async` callback. ▸ *Other stacks:* comprehensions /37 streams / `map` equivalents; the principle (declarative transform, imperative only for effects &38 ordered async) is universal. (Matches global *Code Style — Iteration & Collections*.)3940### 2. Parallelize independent async with `Promise.all`4142Independent awaits run together; only `await` in sequence when one result feeds the next.43```ts44const [detail, owner] = await Promise.all([getDetail(id), getOwner(id)]); // independent → parallel45const files = await Promise.all(media.map(async (m) => ({ name: m.name, data: await toBase64(m) })));46```47A sequential `await` inside a `map`/loop for independent work is the anti-pattern — it serializes48needlessly. ▸ *Other stacks:* `asyncio.gather`, goroutines + `errgroup`, `CompletableFuture.allOf`.4950Cheaper than parallelizing: **don't run what a branch discards.** If some flag/condition zeroes out51or ignores a result, compute that condition FIRST and skip the calls entirely (precedent:52listings-api#719 — an estimate engine ran 2 queries for non-tillable farms whose prices were then53forced to 0). And "moved/ported verbatim" is not an exemption — code you relocate must pass this54section even if its old home didn't.5556### 3. Prefer `null` over `undefined`5758Use an explicit nullable type for "absent" values; reserve `undefined` for "not provided". This59keeps DB-nullable columns, DTO fields, and return types consistent.60```ts61@Column({ nullable: true }) publishedAt!: Nullable<Date>; // entity field62async getFile(req: FileReq): Promise<Nullable<FileDto>> { ... } // return type63```64▸ *Other stacks:* `Optional<T>` (Java), `*T`/`sql.NullString` (Go), `T | None` (Python). The point:65one agreed "absent" value, not a mix of `null` and `undefined`. (TS note: `==`/`!=` against `null`66intentionally matches both `null` and `undefined`.)6768**API response defaults:** an absent **array** is `[]` (never `null`/`undefined`); an absent69**scalar/object** is `null` (never `undefined`). `undefined` disappears from JSON and breaks clients70that expect the key. ▸ *Other stacks:* same — empty collection for lists, explicit null for the rest.7172### 4. Put private helpers below the public API7374A class/file reads top-down: public methods first, then `private` helpers underneath. Helper names75are **verb-led + single responsibility** (`fetchOwnerProfile`, `shouldTriggerReview`, `buildPayload`).76```ts77@Injectable()78export class ListingService {79 async createListing(dto: CreateListingDto) { /* ...delegates to helpers... */ }80 async getListing(id: string) { ... }81 // ── private helpers below ──────────────────────────────82 private async fetchOwnerProfile(listing: Listing) { ... }83 private shouldTriggerReview(listing: Listing): boolean { ... }84}85```86Keep methods to one screenful; extract a focused private method when one covers 3+ concerns.87(Matches global *Code Style — Function Size & Density*.)8889### 5. Querying & performance9091The repository/query builder is where most performance is won or lost. Apply all of these:9293- **Select only the columns you need** — never implicit `SELECT *`.94 ```ts95 .select(['listing.id', 'listing.status', 'listing.price', 'photo']) // explicit, includes joined alias96 ```97- **Push every filter into the query** (status/type/date/soft-delete) and batch multi-value98 filters with `IN (:...ids)` — never fetch broadly then `.filter()` in code, and never query99 per-item in a loop (that's the N+1). (Matches global *Database & Migration Rules*.)100 ```ts101 .where('listing.isDeleted = false')102 .andWhere('listing.status IN (:...statuses)', { statuses }) // one query, not one-per-status103 ```104- **Load relations in the query**, not lazily per row, to avoid N+1: `leftJoinAndSelect` for105 optional relations you need in the result, `innerJoin` for a required relation you only filter on106 (no select), `Brackets` for grouped `OR`.107 ```ts108 .leftJoinAndSelect('listing.photos', 'photo', 'photo.isDeleted = false') // eager, optional109 .innerJoin('listing.owner', 'owner') // required, filter-only110 ```111- **One round-trip when you can** — fetch rows + total together (`getManyAndCount`) instead of two112 queries; combine related work rather than looping queries.113 ```ts114 const [rows, total] = await qb.take(take).skip(skip).orderBy(column, direction).getManyAndCount();115 ```116- **Write-or-update in one statement with upsert** instead of select-then-insert/update; it also117 lets you batch insert + update + soft-delete together.118 ```ts119 await this.photoRepo.upsert(120 [...newPhotos, ...unused.map((p) => ({ ...p, isDeleted: true }))],121 ['id'], // conflict key122 );123 ```124- **Index for your filters + sort, and order deterministically.** Add a composite `@Index` matching125 the common `WHERE` + `ORDER BY`; drive `orderBy` from a typed sort-map constant (not free-text), so126 only indexed columns are sortable.127 ```ts128 @Index('listing_status_createdAt_idx', ['status', 'createdAt'])129 // sort map: enum → { column, direction } ; query: const { column, direction } = listingSortMap[sortBy];130 ```131- **Money & dates go through standard libraries, never ad-hoc math.** Precise/decimal numeric work132 uses a decimal library (floats drift — `0.1 + 0.2 !== 0.3`); date/time uses a date library133 (parsing, timezones, formatting). ▸ *Example:* a `Decimal` type + a `decimal(p,s)` column134 transformer for money; a day/date lib for every date operation. ▸ *Other stacks:* your ecosystem's135 decimal + datetime libs — never hand-roll currency math or timezone arithmetic.136137▸ *Other stacks:* the same SQL discipline applies through any ORM/query layer (Django138`select_related`/`prefetch_related`, JPA fetch joins, `sqlc`/`sqlx`); upsert = `INSERT ... ON139CONFLICT` / `MERGE`.140141### 6. Events & async messaging (SQS/pub-sub)142143- **One event per file under the feature's `events/`**, holding the event class (extends a shared144 `BaseEvent<T>`) **and** its handler; a barrel exports the handler list for batch registration.145- **Emit from the use-case via the bus; never publish externally inline.** The command/query handler146 publishes a domain event; an event handler does the outbound I/O.147 ```ts148 // in a command handler149 this.eventBus.publish(new ListingStatusChangedEvent(listings));150151 // events/listing-status-changed.event.ts152 @EventsHandler(ListingStatusChangedEvent)153 export class ListingStatusChangedEventHandler implements IEventHandler<ListingStatusChangedEvent> {154 async handle(event: ListingStatusChangedEvent): Promise<void> {155 await this.publisher.dispatch({156 event: TOPIC.LISTING_STATUS_CHANGED,157 payload: event.data.map((l) => ({ listingId: l.id, status: l.status })), // mapped subset, NOT the raw entity158 });159 }160 }161 ```162- **Outbound payloads are an explicit mapped subset** (id + the few fields subscribers need), never163 the whole entity — that's a contract you don't want leaking internal columns.164- **Inbound consumers are wired from config + named constants**, not hard-coded URLs/strings. Queue165 URLs are built from a centralized config block; topic/queue/pattern names are constants.166 ```ts167 SqsModule.registerAsync({168 useFactory: (config: ConfigService) => {169 const { region, accountNumber, sqsEndpoint } = config.get(configEvents);170 const consumers = [getQueueName(EVENT_PATTERNS.listing.published)].map((name) => ({171 name, queueUrl: `${sqsEndpoint}/${accountNumber}/${name}`, region,172 }));173 return { consumers };174 },175 inject: [ConfigService],176 });177 // @SqsMessageHandler(queueName, false) async handleMessage(message: Message) { ... }178 ```179- **In a queue (SQS) consumer, never throw — log and return.** An unhandled throw returns the180 message to the queue, where it's re-consumed until it expires — a poison message that can loop181 forever. ▸ *Example:* the handler extends a shared `AbstractEventHandler`; on failure it calls182 `logger.error(...)` then `return`, so the message is acked/deleted while the error still reaches183 alarms. Handle partial-batch failures explicitly (report which records failed). ▸ *Other stacks:*184 ack/commit the message and route the failure to a dead-letter queue — don't let an exception185 trigger blind redelivery.186187▸ *Other stacks:* an in-process event bus or outbox emits; a separate consumer/worker handles the188queue. Principle: **use-case → event → handler → broker**, payloads are explicit, names are config.189190### 7. Logging191192- **Structured metadata, not string concatenation.** A short message string + a context object.193 ```ts194 this.logger.info(`Response from <system> for "${path}"`, { data: maskPii(data), payload: maskPii(payload) });195 this.logger.error("Can't publish listing", { listingId, resError: (error as Error).message });196 ```197- **Mask PII/secrets before logging** (identity numbers, tokens) — pass values through a masker.198- **Levels:** `info` = normal milestones (external call made, status transition), `warn` = recovered/199 fallback paths, `error` = exceptions & failed external calls (include the error message + ids),200 `debug` = detailed tracing. Log every critical branch, fallback, and missing-config path (global201 *Feature Flags & Observability*). Request logging + a global exception filter are wired once202 centrally, not per handler. ▸ *Other stacks:* any structured logger (zap, structlog, SLF4J + MDC) —203 key-value context, not interpolated strings.204205### 8. Writing a test (integration layer)206207These are **integration tests** — they boot the app and hit the real transport/DB boundary. For the208**isolated unit-test layer** (mocked deps, no DB — fast tests for handlers/services/DTOs) see209[write-unit-tests](./write-unit-tests.md); a repo may run one layer or both (two jest projects).210See [structure-a-backend-service](./structure-a-backend-service.md) step 8 for the harness. Per spec:211212```ts213describe('ListingCmdController', () => {214 let client: ClientProxy, app: INestApplication, dataSource: DataSource;215 let factoryCtx: FactoryContext, listingFactory: ListingFactory, listingRepo: Repository<Listing>;216217 beforeAll(() => {218 ({ client, app, dataSource } = global.testContext); // app booted once in the shared setup219 listingRepo = dataSource.getRepository(Listing);220 });221 beforeEach(() => {222 factoryCtx = new FactoryContext(dataSource);223 listingFactory = new ListingFactory(factoryCtx); // fresh factory per test224 });225 afterEach(async () => {226 jest.restoreAllMocks();227 await listingRepo.delete({});228 await factoryCtx.destroy(); // clean state between tests229 });230231 it('publishes the listing and persists it', async () => {232 // Arrange — seed via factory, mock only externals233 const listing = await listingFactory.build({ status: ListingStatus.DRAFT });234 jest.spyOn(SearchIndexService.prototype, 'index').mockResolvedValue({ ok: true });235236 // Act — exactly one action under test, blank line before & after237 const { success } = await lastValueFrom(client.send(PATTERN.listing.publish, msg({ id: listing.id })));238239 // Assert — response AND persisted state, with specific expected values240 expect(success).toBe(true);241 const updated = await listingRepo.findOneOrFail({ where: { id: listing.id } });242 expect(updated.status).toBe(ListingStatus.PUBLISHED); // not toBeTruthy()243 });244245 it('fails for an unknown listing', async () => {246 const { success } = await lastValueFrom(client.send(PATTERN.listing.publish, msg({ id: v4() })));247 expect(success).toBe(false); // cover the error path too248 });249});250```251- **Structure every test Arrange → Act → Assert**, blank line around the Act, **exactly one Act**252 (one action under test) — the comments above show the shape.253- **Seed with factories + a faker lib for inputs** (realistic random names/emails/numbers/ids),254 never hand-rolled literals; build only the fields the test asserts on. Clean up the rows created255 in *this* test afterward (and only those) so parallel specs don't collide.256- **Mock only what crosses the process boundary** (external services via `spyOn(...prototype)`,257 outbound HTTP via an intercept lib). Use the real DB; assert the persisted row, not just the reply.258- **Assert specific expected values, not just truthiness** — `expect(id).toBe(realId)`, not259 `toBeTruthy()`. Common matchers: `toBe` (string/number), `toMatchObject` (part of an object),260 `toEqual` (whole object/array), `arrayContaining` (array members).261- **Group branch variations with `it.each`** instead of copy-pasting near-identical tests.262- **Cover the success path + the error/edge path**; assert outbound side effects with spy-called-with.263- **Keep specs small and run coverage.** One spec per API when it has many cases (≤ ~300 lines);264 group several small APIs into one spec otherwise. Run the coverage report over what you wrote.265- **Don't write isolated unit tests that bypass the boundary** — if it can't be reached through the266 boundary, skip it (global *HTTP-layer testing rule*).267268### 9. Robustness — transactions, event-handler safety, external clients269- **Multi-repo writes go in one transaction; broadcast a compensating event before re-throwing.** Wrap270 related mutations in `dataSource.transaction(em => …)` and pass `em` to each repo op for atomicity.271 In an event-driven flow, if the transaction fails after side effects were signalled, emit a272 **compensating event** (e.g. `OrderCanceled` releasing held inventory) before throwing, so273 downstream can undo:274 ```ts275 await dataSource.transaction(async (em) => {276 const order = await saveOrder(em, dto);277 try { await reserveInventory(em, order); await debitWallet(em, order); return order; }278 catch (e) { await events.publish(new OrderCanceledEvent(order)); throw e; } // compensate, then roll back279 });280 ```281- **In-process event handlers must not crash the bus.** A CQRS `@EventsHandler` is fire-and-forget;282 one failing handler (missing template, third-party down) shouldn't break sibling handlers. Wrap283 `handle` so it logs + swallows instead of throwing — e.g. a shared `@CatchException()` decorator284 (log + return). (This is the in-process twin of the SQS "don't throw" rule in §6.)285- **External/microservice clients own their lifecycle.** A `ClientProxy` (TCP/microservice client)286 **closes on `OnApplicationShutdown`** (no zombie connections on deploy); retry config (attempts +287 delay) is **env-driven** (exponential backoff + jitter, capped); route every call through one base288 `send()` wrapper that centralizes retries + error mapping (don't scatter `clientProxy.send` across services).289 ```ts290 @Injectable() export class WalletClient implements OnApplicationShutdown {291 constructor(@Inject(walletMs) private readonly proxy: ClientProxy) {}292 onApplicationShutdown() { this.proxy.close(); }293 send<I, R>(pattern: string, data: I) { return this.base.sendAsync<I, R>(this.proxy, pattern, data); }294 }295 ```296- **Scope every read/write to the caller's tenant/permitted set.** For non-admin roles, an297 interceptor auto-injects the caller's allowed scope (e.g. their `locationIds`/`orgId`) into the298 query — or **intersects** a requested scope with the allowed set and drops the rest — so a handler299 can't return or mutate another tenant's data even when asked. Centralize this in one300 interceptor/guard; don't re-check in every handler. ▸ *Other stacks:* a query-scoping middleware or301 DB row-level security.302- **Bulk CSV/spreadsheet import** (streaming parse + per-row error report + chunked upsert) has its303 own skill — see `import-data-from-csv`.304- **Background jobs & caching** (Bull queues, Redis cache) have their own skill —305 see `background-jobs-and-caching`.306▸ *Other stacks:* a DB transaction + outbox/compensation; a global error-swallowing wrapper on async307event handlers; one connection-managed client per dependency with retries centralized.308309## Verification310311Reviewing a change, confirm:312313- **Control flow:** no manual index `for`/`while` for transforms (`grep -nE 'for ?\(' src --include='*.ts'`314 should hit only side-effect/ordered-async spots); guard clauses up top; ≤2 nesting levels.315- **Async:** independent awaits are inside a `Promise.all`; no `await` inside a `.map` used for316 independent work.317- **Nullability:** nullable fields/returns use the nullable type, not `undefined`.318- **Layout of logic:** private helpers sit below public methods; helper names are verb-led.319- **Queries:** every list/detail query has an explicit `.select([...])`, filters are in the SQL320 (`IN (:...)` for lists), relations are joined (no per-item query in a loop), pagination uses one321 count+rows call, writes that can conflict use `upsert`, and sorted/filtered columns are indexed.322- **Events:** outbound payload is a mapped subset (not the raw entity); topic/queue names are323 constants; consumers wired from config.324- **Logging:** message + context-object form (no string-concatenated context); PII masked; errors325 log the cause + ids.326- **Tests:** go through the transport/HTTP boundary, seed via factories, assert persisted state, mock327 only externals, cover an error path.328- **Gates:** lint, build, and the test suite pass (e.g. `pnpm lint` / `pnpm build` / `pnpm test`).329330## Related331332- [structure-a-backend-service](./structure-a-backend-service.md) — where these files live and how a333 feature is wired (this skill is the *how to write the code inside*; that one is the *where & what*).334- [README](./README.md) — how project skills are organized.335- **Global assistant instructions** (the authority for the do/don't this skill restates): *Code336 Style — Iteration & Collections*, *Code Style — Function Size & Density*, *Database & Migration337 Rules*, *DRY — Parallel flows*, *Feature Flags & Observability*, *Testing Rules / HTTP-layer338 testing rule*, *Root-Cause First*.