architecture patterns across TypeScript services and modules
Use this as the default rulebook before writing, refactoring, or reviewing TypeScript code.
Source of Truth Hierarchy
When rules conflict, apply in this order:
Repository lint + compiler rules
Existing local pattern in the nearest module/package
This skill's defaults
Do not invent a new local style when an existing style is already established.
Language and Formatting Baseline
TypeScript strict mode is expected (strict family enabled).
2-space indentation.
Verwandte Skills
single quotes.
no semicolons.
trailing commas where formatter applies.
arrow functions only; do not use function declarations.
Naming Conventions
General
Files: kebab-case with purpose suffix where applicable.
Classes: PascalCase with role suffix (Service, Controller, Module, Repository).
Methods/variables: camelCase.
Private members: camelCase, no leading underscore.
Interfaces and Type Parameters
Interface names start with I and use PascalCase.
Generic type parameters start with T and are descriptive (TItem, TResponse, TData).
Booleans
Prefix booleans with intent-bearing verbs:
is, has, can, should, did, will, does, expected
Prefer semantic names over ambiguous forms.
Single-Purpose App Naming
When an app has a single domain (e.g., lambda-image-processing, lambda-subscriptions), drop the domain prefix from class names — the app scope already provides context:
ProcessingService not ImageProcessingService
TransformerService not ImageTransformerService
RepositoryService not ImageEntityRepository
Keep action-descriptive words (Processing, Transformer, Repository) — only drop the redundant domain qualifier.
This rule does NOT apply to multi-domain apps like apps/api/ where the domain prefix disambiguates between modules (e.g., RoutineService vs ExerciseService).
Domain Terms
Prefer canonical domain names over legacy/vendor names.
Keep terms consistent across route, DTO, service, repository, entity, tests, and docs.
File and Module Organization
Keep folders purpose-driven and predictable within the local project conventions.
Match the nearest existing module layout before introducing new structure.
Prefer direct imports over unnecessary local barrels.
Delete barrel files (index.ts) when all consumers already import directly from source files; a barrel with zero consumers is dead code.
Do not create wrapper interfaces, adapter layers, or helper methods unless they reduce real complexity; each layer of indirection adds navigation cost.
Do not create passthrough methods where a public method only packs parameters into a single-use interface to forward to a private method; merge into one method and delete the wrapper interface.
Co-locate related types: config schema types and runtime data types consumed by the same pipeline belong in one file, not split across multiple type files with re-exports between them.
Do not split error classes across multiple files within the same app/module when they share a common base; one errors.ts is sufficient.
Do not create 1-method wrapper services around single infrastructure calls (e.g., a service that only wraps redis.del() or readFile() + YAML parse); inline into the consuming service.
Avoid creating new files unless there is a clear structural reason.
Types and Modeling
interface vs type
Use interface for object shapes and contracts.
Use type for unions, intersections, mapped/utility aliases, and discriminators.
Arrays
Use TItem[], not Array<TItem>.
Inference
Avoid redundant primitive annotations when inference is obvious.
Keep explicit annotations where they improve cross-file readability.
Return Types
Explicit return types are preferred on public methods/functions and exported symbols.
Model nullability intentionally (T | null when explicitly nullable).
Use ? for optional fields.
In DTO Swagger metadata, reflect optional vs nullable precisely.
any vs unknown
Never use any; use unknown and narrow with type guards or validation (e.g., Zod .parse()).
If an external API returns untyped data, receive it as unknown and validate before use.
Derived Types
Prefer Pick, Omit, Partial, and other utility types over manually redeclaring fields.
When a subset of an existing type is needed, derive it: type UserSummary = Pick<User, 'id' | 'name'>.
Non-null Assertions
Acceptable for framework/ORM initialized fields (id!, entity fields).
Acceptable in tests where fixture guarantees existence.
Do not use ! to bypass real uncertainty in application logic.
Type Safety
Do not widen existing domain enums or named type aliases to anonymous string/number types; use the exact existing type (e.g., Gender, MeasurementSystem, DailyTimeCommitment).
Enforce interface alignment with the canonical source type (e.g., align response interface fields with IProfile enum-typed fields).
API Layer Conventions (NestJS)
Controllers
Keep controllers thin: parse/validate/route only.
Delegate business logic to services.
Keep explicit return types.
Use route semantics intentionally (GET read-only, POST mutations, etc.).
Private controller methods that implement non-trivial business logic are a hard review failure; extract that logic into a service.
Authentication and Authorization
Controllers must use the established guard and decorator patterns:
Public endpoints (authenticated user):
@ApiTags('feature')
@Controller('feature')
@UseGuards(FirebaseAuthGuard)
@ApiBearerAuth()
export class FeatureController {
@Get()
public async list(@GetUser() user: IUserPayload): Promise<FeatureListResponse> {
return this.featureService.list(user.userId)
}
}
Both registered in the module's controllers: [FeatureController, FeatureAdminController].
DTOs
Names should describe transport role: *QueryDto, *Request, *Response.
Use class-validator for request validation.
Use class-transformer where conversion is needed.
Swagger decorators must match runtime shape (ApiPropertyOptional, nullable: true, isArray, enumName, type).
Module-Scoped Config
Do not use raw ConfigService.get<T>('ENV_VAR') in feature module services. The correct pattern is a validated config class registered via ConfigModule.forFeature() and injected with @Inject(config.KEY).
Bad — raw access, no validation, duplicated across services:
Infrastructure-level configs from @workspace/* packages (e.g., databaseConfig, storageConfig, redisConfig) use the same registerAs + load pattern but are registered globally in the root module's ConfigModule.forRoot({ load: [...] }).
Service and Repository Boundaries
Services
Own business rules, orchestration, and response shaping.
Use guard clauses to reduce nesting.
Keep method names action-oriented.
Redis Caching Pattern
Services that cache data follow a read-through pattern:
public async getData(id: string): Promise<TData> {
const cacheKey = CACHE_KEYS.RESOURCE(id)
const cached = await this.redisService.get<TData>(cacheKey)
if (cached !== null) {
return cached
}
const data = await this.repository.findById(id)
await this.redisService.set(cacheKey, data, CACHE_TTL_SECONDS)
return data
}
Cache keys are defined in apps/api/src/shared/cache.constants.ts. Workers and webhooks invalidate these keys after writes.
Repositories
Keep persistence-focused and predictable.
Prefer typed return shapes.
Keep API response mapping in services unless repository contract is intentionally API-oriented.
For paginated database-backed reads, apply filtering and pagination in the database query, never in memory.
Ensure deterministic ordering at query level before pagination (ORDER BY + LIMIT/OFFSET or cursor predicate).
Async and Concurrency
No floating promises.
Use void for intentional fire-and-forget.
Do not mark function async when it does not await.
Use Promise.all for independent operations.
Keep sequential await when order matters.
Error Handling
Prefer typed/domain-specific errors over generic Error.
Convert low-level errors to stable application-facing errors at boundaries.
Keep messages actionable and specific.
Commenting and Documentation
Keep code mostly self-documenting.
Add comments only for non-obvious constraints and side-effect ordering.
Comments explain "why", not "what". Avoid comments that restate code.
Complex logic has explanatory comments or is broken into named steps.
No @ts-ignore or @ts-expect-error; fix the underlying type issue instead.
No eslint-disable unless there is a documented, unavoidable reason inline.
Testing Alignment
Use the project's shared setup helpers and factories when they exist.
Keep assertions strict and aligned with contract.
Keep tests deterministic and isolated.
Architecture Principles
Appropriate separation of concerns — no god functions, no mixed layers.
No unnecessary coupling between modules.
No premature abstraction — abstraction must be earned by real duplication.
No over-modularization in single-purpose apps — lambdas/workers with one pipeline should use flat module structure, not core/ + feature/ hierarchies.
No 3-layer service chains where the intermediate adds no branching logic — collapse to 2 layers (orchestrator + specialized).
No standalone helper files with functions used in exactly one place — inline as private methods.
Code Quality Principles
Behavior-preserving by default.
Apply YAGNI: do not add features that are not needed now.
Prefer WET over unnecessary abstraction (Rule of Three: write it once — just write it; write it twice — notice but resist abstracting; write it a third time — now evaluate whether the cases share the same reason to change; if yes abstract, if no keep separate).
Keep implementations smaller: reduce real logic surface, not cosmetic one-liners/comment removal.
Do not extract code into one-off helpers/methods by default; only extract when it clearly reduces real complexity (including linter-driven complexity/size limits), materially improves readability with a well-defined responsibility, and has a strong likelihood of reuse.
Treat functions/methods with many seemingly unrelated parameters as a code smell; investigate whether responsibilities should be clarified or split by concern (without splitting for the sake of splitting).
Eliminate unnecessary indirection: every layer of wrapping (trivial methods, single-use wrapper interfaces, barrel files, parameter objects used at one call site) adds navigation cost; remove wrappers that do not reduce real complexity.
Avoid verbose data transformations: when a source structure already matches the target type, assign directly instead of copying field-by-field; field-by-field mapping is only justified when transformation, validation, or field subsetting occurs.
Avoid redundant work: when an expensive operation (IO, parsing, computation) has already been performed and its result is available, pass the result down instead of re-executing the operation.
No magic strings or numbers; extract into named constants.
Wrap multi-write operations in transactions.
No any; use unknown and narrow with type guards or validation.
Derive types from existing ones (Pick, Omit, Partial) instead of copy-pasting fields.
Remove dead code aggressively — unused methods/types/exports/tests/config keys/dependencies.
Do not keep compatibility shims unless explicitly requested.
Do not add backwards compatibility layers unless explicitly requested by the user. If there is strong reasoning to add backwards compatibility, pause and ask user clarification before implementing.
Query and Data Access
No SQL/ORM N+1 query patterns; verify query efficiency on touched paths.
Fetch only needed columns; avoid over-fetching; paginate large result sets.
HARD FAIL: for database-backed paginated results, perform both filtering and pagination in the database query layer; never filter/slice in memory.
Define deterministic ordering for any user-visible list, pagination, queue, or time-sequenced processing.
Verify supporting indexes exist for new/changed query paths (including composites matching WHERE + ORDER BY); remove redundant indexes.
No injection-prone patterns (SQL/NoSQL/command/template); use parameterization/safe APIs.
No multi-write operations without a transaction boundary.
Preferred Patterns
One-off extraction policy
default to inline code when extracted helpers would be called only once
extract only when one of these is true: complexity/size reduction (including lint constraints), clearly better readability with a well-defined responsibility, or likely near-term reuse
rationale: avoid deep one-time call chains that force readers to jump across multiple functions and reduce navigability
// ❌ single-line return is not allowed
if (!user) return null
// ✅ always use braces + multiline
if (!user) {
return null
}
if (!user.isActive) {
return null
}
if (!hasPermission(user)) {
return null
}
No escape hatches
// ❌
// @ts-ignore
// @ts-expect-error
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const data: any = value
// ✅ fix the actual problem
const data: unknown = value
if (isUser(data)) { ... }
Refactor-Oriented Rules
Rename completely across DTOs/imports/tests/docs.
Preserve contracts unless behavior change is explicit.
Remove dead types instead of keeping compatibility stubs.
Do not add backwards compatibility layers unless explicitly requested by the user.
If there is strong reasoning to add backwards compatibility, pause and ask user clarification before implementing.
Keep schema/entity alignment.
Keep required vs optional semantics truthful across layers.
Remove unnecessary indirection: inline trivial wrappers, replace single-use parameter objects with direct parameters, delete unused barrels.
Simplify data transformations: when source and target types match structurally, assign directly instead of field-by-field copying.
Eliminate redundant work: pass already-computed results (metadata, validation outcomes, parsed data) to callees instead of re-computing.
Refactor Playbooks
Indirection reduction
identify trivial wrapper methods that only delegate to another method with a hardcoded argument; inline the call at all call sites and remove the wrapper
identify single-use parameter wrapper interfaces/types (an interface created only to bundle arguments for one private method call); replace with direct parameters, especially when parallel methods in the same class already use direct parameters (consistency matters)
identify barrel files (index.ts) that merely re-export siblings without adding value; if all consumers already import directly from the source files, delete the barrel
identify wrapper objects or adapter layers that pass data through without transformation; remove the layer and let consumers use the source directly
identify passthrough methods where a public method's only job is to pack its parameters into a single-use interface and forward to a private method; merge into one method (the private method becomes the public one) and delete the wrapper interface
Verbose transformation reduction
when loading config, deserializing data, or mapping between layers, check whether the source shape already matches the target type
if it does, assign directly instead of copying field-by-field; field-by-field mapping is only warranted when fields are renamed, transformed, validated, or subsetted
watch for mapping code that expands as new fields are added but never actually transforms anything — this is a maintenance surface with no value
Redundant work elimination
when a method calls an expensive operation (IO, parsing, metadata extraction) and then passes the raw input to a sub-method that repeats the same operation, pass the result down instead
common pattern: metadata/validation fetched in an outer method, then re-fetched in an inner method on the same input — add a parameter for the already-computed result
Module flattening (single-purpose apps)
When a lambda or worker app has multiple NestJS modules (core/, feature/) but only one pipeline:
Identify whether the core/ module contains any service with more than one consumer across modules.
If every core/ service is consumed only by the feature module, merge all files into a single flat module/ directory.
Inline standalone helper files (functions used in exactly one place) as private methods.
Merge thin wrapper services (1-method services around infrastructure calls like redis.del(), readFile()) into the consuming service.
Consolidate multiple type files into a single schema file when all types serve the same pipeline.
Merge multiple error files into a single errors.ts.
Drop redundant domain prefix from class names when the app scope already implies it (e.g., ProcessingService not ImageProcessingService inside lambda-image-processing).
Update the root module to a flat providers list — no sub-module imports.
Service chain collapse
When a service chain has 3+ layers (orchestrator → intermediate → specialized) and the intermediate service adds no branching logic or independent state:
Identify what the intermediate service actually contributes — if it only forwards calls and aggregates results, it's a passthrough layer.
Move the intermediate service's logic into the orchestrator.
Let the orchestrator call the specialized service directly.
Delete the intermediate service and update module providers.
Example: ImageProcessingService → ImageProcessorService → ImageTransformerService collapsed to ProcessingService → TransformerService — the orchestrator now owns the full pipeline (download, validate, blurhash, resize, upload).
Re-export and type alias cleanup
Identify export type { X } re-exports where the consuming files could import directly from the source.
Identify type files that exist solely to hold 1-2 interfaces consumed by one other file — merge into the consumer's schema file.
When config types (e.g., IEntityConfig) and runtime data types (e.g., IMediaItemData) are consumed by the same pipeline, co-locate them in one schema file.
do not keep compatibility shims unless explicitly requested
Post-change dead code verification
After removing a dependency, import, or injection from a service/module, run codebase_find_unused_symbols scoped to the affected module to detect methods or symbols that became dead as a side effect. Either clean them up in the same PR or log a follow-up ticket. Do not leave newly-dead code unacknowledged.
Refactor-session unused symbol sweep
At least once during every refactor session, run codebase_find_unused_symbols on the touched scope (module/package or whole project, depending on blast radius). For each candidate, determine whether it is truly dead code or an exported symbol potentially used externally. If the list is large or ambiguous, dispatch task sub-agents to investigate and classify candidates before removal.
quality gate: zero ESLint errors and zero TypeScript errors
Anti-Patterns
compatibility shims for removed APIs
backwards compatibility added without explicit user request
partial domain renames
broad lint-disable blocks with no reason
unnecessary local barrels
stale TODOs after refactor completion
putting admin endpoints on the public controller
omitting @UseGuards(FirebaseAuthGuard) on controller classes
omitting @GetUser() when the service needs the authenticated user's ID
magic strings or numbers; extract into named constants
using any instead of unknown + narrowing
function declarations instead of arrow functions
@ts-ignore or @ts-expect-error instead of fixing the type issue
copy-pasting type fields instead of deriving with Pick/Omit/Partial
in-memory filtering/slicing for paginated results backed by database queries
unnecessary indirection: trivial wrapper methods that only delegate with a hardcoded argument, single-use parameter wrapper interfaces for private methods, barrel files with no consumers, adapter layers that pass through without transforming
verbose field-by-field data mapping when source structure already matches target type (assign directly instead)
inconsistent parameter style across sibling methods in the same class (e.g., one method uses direct params, another uses a wrapper object for the same pattern)
redundant expensive operations: calling the same IO/parsing/metadata operation twice on the same input when the result could be passed down
passthrough methods: a public method whose only job is to pack its parameters into a single-use interface and forward to a private method (merge into one method)
exported symbols with zero consumers anywhere in the project (dead exports)
speculative properties/parameters: fields declared on error classes, config objects, or DTOs that nothing in the codebase reads (YAGNI — remove unless there is evidence of external consumption)
multi-module hierarchies (core/ + feature/) in single-purpose apps (lambdas, workers) that have only one pipeline; use a flat module structure instead
redundant domain prefix in class names when the app scope already implies the domain (e.g., ImageProcessingService inside lambda-image-processing)
standalone helper files with functions used in exactly one place; inline as private methods
re-export type aliases that add no transformation (e.g., export type { IBlurhashSchema } from a file that imports it from another type file)
3-layer service chains (orchestrator → intermediate → specialized) where the intermediate adds no branching logic; collapse to 2 layers
split type files for one pipeline: config types, data types, and error types for the same domain scattered across 3+ files when they could be 1-2 files
widening existing domain enums or named type aliases to anonymous string/number types
no single-line if statements (including return, throw, continue, break); always use braces and multiline blocks
no escape hatches: no @ts-ignore, no @ts-expect-error, no eslint-disable, no any
no SQL/ORM N+1 query patterns
no multi-write operations without a transaction boundary
no injection-prone patterns (SQL/NoSQL/command/template)
no orphan imports
no stale test names/routes/docs
no API doc drift
no hidden behavior changes
no unnecessary file splits
no unsorted user-visible lists or paginated endpoints
no newly-dead symbols left behind after dependency removal (verify with codebase_find_unused_symbols)