Install
$ agentstack add skill-kennguyen887-agent-foundation-write-service-code ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
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](./structure-a-backend-service.md).
Each rule: portable principle → ▸ Example (TS/NestJS) (neutral listing domain; `/` = 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. ``ts 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). ``ts 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); ``
- Imperative iteration only for: side effects (
forEach/for...ofto 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.
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; // entity field
async getFile(req: FileReq): Promise> { ... } // return type
▸ Other stacks: Optional (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 *.
``ts .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.) ``ts .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:
leftJoinAndSelectfor
optional relations you need in the result, innerJoin for a required relation you only filter on (no select), Brackets for grouped OR. ``ts .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. ``ts 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. ``ts 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
@Indexmatching
the common WHERE + ORDER BY; drive orderBy from a typed sort-map constant (not free-text), so only indexed columns are sortable. ``ts @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) 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. ```ts // in a command handler this.eventBus.publish(new ListingStatusChangedEvent(listings));
// events/listing-status-changed.event.ts @EventsHandler(ListingStatusChangedEvent) export class ListingStatusChangedEventHandler implements IEventHandler { async handle(event: ListingStatusChangedEvent): Promise { await this.publisher.dispatch({ event: TOPIC.LISTINGSTATUSCHANGED, 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. ``ts 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.
``ts this.logger.info(Response from 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](./write-unit-tests.md); a repo may run one layer or both (two jest projects). See [structure-a-backend-service](./structure-a-backend-service.md) step 8 for the harness. Per spec:
describe('ListingCmdController', () => {
let client: ClientProxy, app: INestApplication, dataSource: DataSource;
let factoryCtx: FactoryContext, listingFactory: ListingFactory, listingRepo: Repository;
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.eachinstead 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: ``ts 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
@EventsHandleris 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). ```ts @Injectable() export class WalletClient implements OnApplicationShutdown { constructor(@Inject(walletMs) private readonly proxy: ClientProxy) {} onApplicationShutdown() { this.proxy.close(); } send(pattern: string, data: I) { return this.base.sendAsync(this.proxy, pattern, data); } }
…
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: kennguyen887
- Source: kennguyen887/agent-foundation
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.