Created
March 11, 2026 06:09
-
-
Save daneuchar/860b9dadff89d9ee417dd032ac524f18 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Clean Code Rules | |
| # Stack: TypeScript · Node.js · Apollo Federation · GraphQL · Kafka · EventHub | |
| # These rules are non-negotiable across every package and subgraph in the monorepo. | |
| --- | |
| ## 1. SIZE LIMITS — hard ceilings, not guidelines | |
| | Unit | Max Lines | Rationale | | |
| |-----------------------|-----------|--------------------------------------------------------| | |
| | File | 300 | Beyond this, the file owns too many concerns | | |
| | Class | 150 | A class that needs more is doing too much | | |
| | Function / Method | 20 | Fits on one screen, testable in isolation | | |
| | Arrow function | 10 | Inline logic that needs more belongs in a named fn | | |
| | Resolver | 10 | Resolvers delegate — they never compute | | |
| | Constructor | 10 | Assign, never compute inside a constructor | | |
| | Interface | 15 | Large interfaces signal Interface Segregation failure | | |
| | Cyclomatic complexity | 5 | Max decision branches per function (if/else/switch/&&) | | |
| | Cognitive complexity | 7 | Max mental steps to trace one function end-to-end | | |
| | Parameters | 3 | More than 3 → introduce a config/options object | | |
| | Nesting depth | 3 | Beyond 3 levels → extract or use early returns | | |
| ### How to fix violations | |
| ```typescript | |
| // ❌ 6 parameters — impossible to call correctly without looking at the signature | |
| function createOrder( | |
| userId: string, | |
| productId: string, | |
| quantity: number, | |
| couponCode: string, | |
| shippingAddress: string, | |
| priority: boolean | |
| ): Promise<Order> { ... } | |
| // ✅ Options object — named, extensible, self-documenting at the call site | |
| interface CreateOrderOptions { | |
| userId: string; | |
| productId: string; | |
| quantity: number; | |
| couponCode?: string; | |
| shippingAddress: string; | |
| priority?: boolean; | |
| } | |
| function createOrder(options: CreateOrderOptions): Promise<Order> { ... } | |
| // ❌ 4 levels of nesting — impossible to read | |
| async function processOrder(order: Order) { | |
| if (order) { | |
| if (order.items.length > 0) { | |
| for (const item of order.items) { | |
| if (item.stock > 0) { | |
| // actual logic buried here | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // ✅ Early returns flatten the nesting — logic is immediately visible | |
| async function processOrder(order: Order) { | |
| if (!order) return; | |
| if (order.items.length === 0) return; | |
| for (const item of order.items) { | |
| if (item.stock > 0) await reserveStock(item); | |
| } | |
| } | |
| ``` | |
| --- | |
| ## 2. SOLID PRINCIPLES — applied to every layer | |
| ### S — Single Responsibility | |
| One class, one reason to change. One function, one job. | |
| ```typescript | |
| // ❌ OrderService doing data access, business logic, AND email sending | |
| class OrderService { | |
| async placeOrder(input: PlaceOrderInput) { | |
| const order = await this.db.orders.create(input); // data access | |
| if (order.total > 1000) order.priority = true; // business rule | |
| await this.emailClient.send('order-confirmation', ...); // side effect | |
| return order; | |
| } | |
| } | |
| // ✅ Each class owns exactly one responsibility | |
| class OrderRepository { | |
| async create(input: PlaceOrderInput): Promise<Order> { ... } | |
| } | |
| class OrderService { | |
| constructor( | |
| private readonly repo: OrderRepository, | |
| private readonly events: IEventBus, | |
| ) {} | |
| async placeOrder(input: PlaceOrderInput): Promise<Order> { | |
| const order = await this.repo.create(input); | |
| await this.events.publish(new OrderPlacedEvent(order)); | |
| return order; | |
| } | |
| } | |
| class OrderNotificationHandler { | |
| async handle(event: OrderPlacedEvent): Promise<void> { | |
| // email logic lives here, not in OrderService | |
| } | |
| } | |
| ``` | |
| ### O — Open / Closed | |
| Open for extension, closed for modification. | |
| Add behaviour through new classes or plugins — never by editing existing ones. | |
| ```typescript | |
| // ❌ Adding a new payment method forces edits inside processPayment | |
| function processPayment(method: string, amount: number) { | |
| if (method === 'stripe') { ... } | |
| else if (method === 'paypal') { ... } | |
| else if (method === 'apple-pay') { ... } // new method = modifying this file | |
| } | |
| // ✅ New payment method = new class, zero changes to existing code | |
| interface IPaymentProvider { | |
| charge(amount: number, currency: string): Promise<PaymentResult>; | |
| } | |
| class StripeProvider implements IPaymentProvider { ... } | |
| class PayPalProvider implements IPaymentProvider { ... } | |
| class ApplePayProvider implements IPaymentProvider { ... } // just add a new file | |
| class PaymentService { | |
| constructor(private readonly provider: IPaymentProvider) {} | |
| charge(amount: number, currency: string) { | |
| return this.provider.charge(amount, currency); | |
| } | |
| } | |
| ``` | |
| ### L — Liskov Substitution | |
| Any subtype must be a complete substitute for its parent. | |
| Never override a method to throw or do nothing. | |
| ```typescript | |
| // ❌ MockEmailService breaks the contract — callers can't trust the interface | |
| class MockEmailService extends EmailService { | |
| async send() { throw new Error('Not implemented'); } | |
| } | |
| // ✅ Implement the full interface — use a no-op if needed | |
| class MockEmailService implements IEmailService { | |
| async send(_to: string, _template: string): Promise<void> { | |
| // intentional no-op in tests — but the signature contract is honoured | |
| } | |
| } | |
| ``` | |
| ### I — Interface Segregation | |
| Clients must not depend on methods they don't use. | |
| Split fat interfaces into focused ones. | |
| ```typescript | |
| // ❌ Every consumer is forced to implement methods it doesn't need | |
| interface IUserService { | |
| findById(id: string): Promise<User>; | |
| create(input: CreateUserInput): Promise<User>; | |
| update(id: string, input: UpdateUserInput): Promise<User>; | |
| delete(id: string): Promise<void>; | |
| sendVerificationEmail(id: string): Promise<void>; | |
| generateReport(): Promise<Buffer>; | |
| } | |
| // ✅ Focused interfaces — consumers depend only on what they use | |
| interface IUserReader { | |
| findById(id: string): Promise<User | null>; | |
| } | |
| interface IUserWriter { | |
| create(input: CreateUserInput): Promise<User>; | |
| update(id: string, input: UpdateUserInput): Promise<User>; | |
| delete(id: string): Promise<void>; | |
| } | |
| interface IUserNotifier { | |
| sendVerificationEmail(id: string): Promise<void>; | |
| } | |
| ``` | |
| ### D — Dependency Inversion | |
| Depend on abstractions (interfaces), never on concrete classes. | |
| Inject all dependencies — never instantiate inside a class. | |
| ```typescript | |
| // ❌ Hard dependency on concrete class — untestable, tightly coupled | |
| class OrderService { | |
| private repo = new OrderRepository(); // ❌ concrete, un-injectable | |
| private events = new KafkaEventBus(); // ❌ requires a real Kafka broker in tests | |
| } | |
| // ✅ Depend on interfaces — inject at composition root | |
| class OrderService { | |
| constructor( | |
| private readonly repo: IOrderRepository, // ✅ interface | |
| private readonly events: IEventBus, // ✅ interface | |
| private readonly logger: ILogger, // ✅ interface | |
| ) {} | |
| } | |
| // Wired at composition root (index.ts / server.ts) only | |
| const orderService = new OrderService( | |
| new OrderRepository(db), | |
| new KafkaEventBus(kafkaClient), | |
| new PinoLogger(), | |
| ); | |
| ``` | |
| --- | |
| ## 3. DESIGN PATTERNS — when to use each | |
| ### Repository Pattern | |
| **Use for:** All database access. Nothing outside a repository touches SQL, ORM queries, | |
| or raw DB clients. | |
| ```typescript | |
| interface IOrderRepository { | |
| findById(id: string): Promise<Order | null>; | |
| findByUserId(userId: string): Promise<Order[]>; | |
| create(input: CreateOrderData): Promise<Order>; | |
| update(id: string, data: Partial<Order>): Promise<Order>; | |
| } | |
| class OrderRepository implements IOrderRepository { | |
| constructor(private readonly db: Database) {} | |
| findById(id: string): Promise<Order | null> { | |
| return this.db.orders.findUnique({ where: { id } }); | |
| } | |
| findByUserId(userId: string): Promise<Order[]> { | |
| return this.db.orders.findMany({ where: { userId } }); | |
| } | |
| } | |
| ``` | |
| ### Factory Pattern | |
| **Use for:** Creating DataLoaders, event producers, consumers, and anything that varies | |
| by environment or configuration. | |
| ```typescript | |
| // ❌ Instantiation logic scattered across the codebase | |
| const loader = new DataLoader(async (ids) => { ... }); | |
| // ✅ Factory centralises creation — easy to swap in tests | |
| function createUserLoader(db: Database): DataLoader<string, User> { | |
| return new DataLoader(async (ids: readonly string[]) => { | |
| const users = await db.users.findManyByIds([...ids]); | |
| const map = new Map(users.map(u => [u.id, u])); | |
| return ids.map(id => map.get(id) ?? null); | |
| }); | |
| } | |
| // All loaders created in one place — loaders.factory.ts | |
| export function createLoaders(db: Database): Loaders { | |
| return { | |
| userLoader: createUserLoader(db), | |
| orderLoader: createOrderLoader(db), | |
| productLoader: createProductLoader(db), | |
| }; | |
| } | |
| ``` | |
| ### Strategy Pattern | |
| **Use for:** Swappable algorithms or behaviours — serialization, payment providers, | |
| notification channels, event bus adapters. | |
| ```typescript | |
| // The event bus uses Strategy to route to Kafka or EventHub | |
| interface IProducer { | |
| send(topic: string, event: BaseEvent): Promise<void>; | |
| } | |
| class EventBus { | |
| constructor(private readonly producer: IProducer) {} // strategy injected | |
| publish(event: BaseEvent): Promise<void> { | |
| return this.producer.send(event.type, event); | |
| } | |
| } | |
| // Switch strategy without changing EventBus | |
| const bus = new EventBus(new KafkaProducer(kafkaClient)); | |
| const testBus = new EventBus(new InMemoryProducer()); | |
| ``` | |
| ### Observer Pattern (via Event Bus) | |
| **Use for:** Decoupling producers from consumers across subgraph boundaries. | |
| Mutations publish events; consumers react independently. | |
| ```typescript | |
| // Producer — knows nothing about who is listening | |
| class OrderService { | |
| async placeOrder(input: PlaceOrderInput): Promise<Order> { | |
| const order = await this.repo.create(input); | |
| await this.eventBus.publish(new OrderPlacedEvent(order)); // fire and observe | |
| return order; | |
| } | |
| } | |
| // Consumer — knows nothing about who published | |
| class InventoryConsumer implements IConsumer { | |
| async onMessage(event: OrderPlacedEvent): Promise<void> { | |
| await this.inventoryService.reserveStock(event.payload.items); | |
| } | |
| } | |
| ``` | |
| ### Adapter Pattern | |
| **Use for:** Wrapping third-party SDKs (KafkaJS, @azure/event-hubs) behind your own | |
| interfaces. External APIs change — your interface must not. | |
| ```typescript | |
| // Your interface — stable, owned by you | |
| interface IProducer { | |
| send(topic: string, event: BaseEvent): Promise<void>; | |
| } | |
| // KafkaJS adapter — wraps the external SDK | |
| class KafkaProducer implements IProducer { | |
| constructor(private readonly producer: KafkaJSProducer) {} | |
| async send(topic: string, event: BaseEvent): Promise<void> { | |
| await this.producer.send({ | |
| topic, | |
| messages: [{ value: JSON.stringify(event) }], | |
| }); | |
| } | |
| } | |
| // EventHub adapter — same interface, different SDK | |
| class EventHubProducer implements IProducer { | |
| constructor(private readonly client: EventHubProducerClient) {} | |
| async send(topic: string, event: BaseEvent): Promise<void> { | |
| const batch = await this.client.createBatch({ partitionKey: topic }); | |
| batch.tryAdd({ body: event }); | |
| await this.client.sendBatch(batch); | |
| } | |
| } | |
| ``` | |
| ### Builder Pattern | |
| **Use for:** Constructing complex objects with many optional fields — query filters, | |
| Kafka consumer configs, test fixtures. | |
| ```typescript | |
| class OrderFilterBuilder { | |
| private filter: OrderFilter = {}; | |
| withStatus(status: OrderStatus): this { | |
| this.filter.status = status; | |
| return this; | |
| } | |
| withUserId(userId: string): this { | |
| this.filter.userId = userId; | |
| return this; | |
| } | |
| withDateRange(from: Date, to: Date): this { | |
| this.filter.createdAfter = from; | |
| this.filter.createdBefore = to; | |
| return this; | |
| } | |
| build(): OrderFilter { | |
| return { ...this.filter }; | |
| } | |
| } | |
| // Usage — reads like a sentence | |
| const filter = new OrderFilterBuilder() | |
| .withStatus(OrderStatus.PENDING) | |
| .withUserId('user-123') | |
| .withDateRange(startOfMonth, now) | |
| .build(); | |
| ``` | |
| ### Decorator Pattern | |
| **Use for:** Adding cross-cutting concerns (logging, retry, metrics) to services without | |
| modifying the service class. Use Envelop plugins for GraphQL-layer concerns. | |
| ```typescript | |
| // ✅ Logging decorator — wraps a service without touching it | |
| class LoggingOrderRepository implements IOrderRepository { | |
| constructor( | |
| private readonly inner: IOrderRepository, | |
| private readonly logger: ILogger, | |
| ) {} | |
| async findById(id: string): Promise<Order | null> { | |
| this.logger.debug({ id }, 'OrderRepository.findById called'); | |
| const start = Date.now(); | |
| const result = await this.inner.findById(id); | |
| this.logger.debug({ id, ms: Date.now() - start }, 'findById completed'); | |
| return result; | |
| } | |
| } | |
| // Stacked at composition root — order of decorators is explicit | |
| const repo = new LoggingOrderRepository( | |
| new OrderRepository(db), | |
| logger, | |
| ); | |
| ``` | |
| --- | |
| ## 4. NAMING RULES | |
| ``` | |
| Variables → camelCase, intent-revealing nouns | |
| ✅ userOrderCount ❌ n, data, res, temp, obj | |
| Boolean vars → is / has / can / should prefix | |
| ✅ isAuthenticated, hasActiveSubscription, canRefund | |
| ❌ authenticated, activeSubscription, refund | |
| Functions → camelCase, verb + noun | |
| ✅ findOrderById, publishUserCreatedEvent, validateInput | |
| ❌ order, process, handle, doStuff | |
| Async functions → same as functions — never suffix with Async | |
| ✅ findOrderById ❌ findOrderByIdAsync | |
| Classes → PascalCase, concrete nouns | |
| ✅ KafkaProducer, OrderRepository, UserService | |
| ❌ KafkaProducerHelper, OrderMgr, UserUtils | |
| Interfaces → PascalCase, prefixed with I | |
| ✅ IOrderRepository, IEventBus, IUserService | |
| (the I prefix makes injection sites instantly readable) | |
| Enums → PascalCase name, SCREAMING_SNAKE_CASE values | |
| ✅ enum OrderStatus { PENDING, IN_PROGRESS, FULFILLED } | |
| Constants → SCREAMING_SNAKE_CASE for module-level, camelCase for local | |
| ✅ MAX_RETRY_ATTEMPTS = 3 const maxRetries = 3 | |
| Event classes → PastTense — events describe things that already happened | |
| ✅ OrderPlacedEvent, UserCreatedEvent, PaymentFailedEvent | |
| ❌ PlaceOrderEvent, CreateUserEvent | |
| Files → kebab-case, suffix signals the role of the file | |
| ✅ order.service.ts order.repository.ts order.resolvers.ts | |
| order-placed.producer.ts kafka.consumer.ts | |
| No abbreviations unless universally known (id, url, http, db, ctx) | |
| ✅ userId, httpClient, dbConnection | |
| ❌ usrId, httpCli, dbConn, mgr, svc, util | |
| ``` | |
| --- | |
| ## 5. FUNCTION & CLASS RULES | |
| ``` | |
| Functions | |
| ───────────────────────────────────────────────────────────────── | |
| ✅ Do one thing — if you need "and" to describe it, split it | |
| ✅ Pure where possible — same input always produces same output | |
| ✅ Explicit return types on all public functions | |
| ✅ Async / await — never mix with raw .then() chains | |
| ✅ No side effects inside query functions (find*, get*, calculate*) | |
| ✅ Guard clauses first — return early for invalid / edge cases | |
| ✅ Max 3 parameters — use an options object beyond that | |
| ❌ No boolean parameters that flip behaviour (flag arguments) | |
| Use two functions: createUser() / createAdminUser() | |
| ❌ No output parameters — return values, never mutate arguments | |
| ❌ No dead code — delete it, git history remembers it | |
| Classes | |
| ───────────────────────────────────────────────────────────────── | |
| ✅ One public responsibility, clearly named | |
| ✅ Depend on interfaces, not concrete classes | |
| ✅ All dependencies injected via constructor | |
| ✅ Constructor assigns only — no logic, no async work | |
| ✅ Private fields for everything not part of the public API | |
| ✅ No static mutable state — use singletons sparingly via DI | |
| ✅ Prefer composition over inheritance | |
| ❌ No God classes — if it touches more than 2 domains, split it | |
| ❌ No utility classes full of static methods — use plain functions | |
| ❌ No data classes with public mutable fields — use private + getters | |
| ``` | |
| --- | |
| ## 6. ERROR HANDLING RULES | |
| ```typescript | |
| // ❌ Swallowing errors — the worst sin | |
| try { | |
| await publishEvent(event); | |
| } catch (e) { | |
| // nothing — this hides failures silently | |
| } | |
| // ❌ Catching Error and rethrowing a string — loses the stack trace | |
| try { | |
| await publishEvent(event); | |
| } catch (e) { | |
| throw 'Event publish failed'; | |
| } | |
| // ✅ Typed domain errors — descriptive, catchable by type | |
| class EventPublishError extends Error { | |
| constructor( | |
| public readonly topic: string, | |
| public readonly cause: unknown, | |
| ) { | |
| super(`Failed to publish to topic "${topic}"`); | |
| this.name = 'EventPublishError'; | |
| } | |
| } | |
| // ✅ Always log + rethrow or log + handle — never swallow | |
| try { | |
| await kafkaProducer.send(topic, event); | |
| } catch (cause) { | |
| logger.error({ topic, cause }, 'Kafka publish failed'); | |
| throw new EventPublishError(topic, cause); | |
| } | |
| // ✅ Exhaustive error handling with discriminated unions | |
| type Result<T, E extends Error = Error> = | |
| | { ok: true; value: T } | |
| | { ok: false; error: E }; | |
| async function placeOrder(input: PlaceOrderInput): Promise<Result<Order>> { | |
| try { | |
| const order = await repo.create(input); | |
| return { ok: true, value: order }; | |
| } catch (cause) { | |
| return { ok: false, error: new OrderCreationError(input, cause) }; | |
| } | |
| } | |
| ``` | |
| --- | |
| ## 7. COMMENTS RULES | |
| ``` | |
| ✅ Write comments for WHY — never for WHAT | |
| Code explains what. Comments explain decisions, trade-offs, and gotchas. | |
| ✅ JSDoc on all public interfaces, classes, and functions | |
| /** Finds an order by ID. Returns null if not found. Never throws. */ | |
| ✅ TODO / FIXME must include a ticket reference | |
| // TODO(ORD-412): remove after EventHub migration is complete | |
| ❌ No commented-out code — delete it | |
| ❌ No redundant comments that restate the code | |
| // increment counter by 1 | |
| counter++; | |
| ❌ No misleading comments — a wrong comment is worse than no comment | |
| ``` | |
| --- | |
| ## 8. IMPORTS & MODULE RULES | |
| ```typescript | |
| // Import order — enforced by ESLint (enforce with eslint-plugin-import) | |
| // 1. Node built-ins | |
| import { EventEmitter } from 'events'; | |
| // 2. External packages | |
| import { Kafka } from 'kafkajs'; | |
| import { ApolloServer } from '@apollo/server'; | |
| // 3. Internal monorepo packages (workspace imports) | |
| import { IEventBus } from '@company/eventing'; | |
| import { GraphQLContext } from '@company/context'; | |
| // 4. Local relative imports | |
| import { OrderRepository } from '../repositories/order.repository'; | |
| import { OrderPlacedEvent } from './events/order-placed.event'; | |
| // Rules | |
| ✅ Barrel imports for internal packages only (index.ts) | |
| ✅ Named exports everywhere — no default exports (hurts refactoring) | |
| export class OrderService { ... } ✅ | |
| export default class OrderService { ... } ❌ | |
| ✅ Absolute paths via tsconfig paths — never ../../.. chains | |
| import { IEventBus } from '@company/eventing' ✅ | |
| import { IEventBus } from '../../../packages/eventing/src' ❌ | |
| ``` | |
| --- | |
| ## 9. TYPESCRIPT RULES | |
| ```typescript | |
| ✅ Strict mode always — tsconfig strict: true is non-negotiable | |
| ✅ Explicit return types on all public methods and functions | |
| ✅ unknown over any — narrow with type guards before use | |
| ✅ readonly on constructor parameters and arrays that must not mutate | |
| ✅ Discriminated unions over optional fields for variant types | |
| ✅ Type guards over type assertions (as Type) | |
| // ❌ Suppressing the compiler — if you need this, rethink the design | |
| const value = result as unknown as MyType; | |
| // @ts-ignore | |
| // ✅ Discriminated union — exhaustive, type-safe, zero ambiguity | |
| type EventResult = | |
| | { status: 'published'; messageId: string } | |
| | { status: 'failed'; error: EventPublishError } | |
| | { status: 'skipped'; reason: string }; | |
| // ✅ Type guard — narrow unknown before use | |
| function isOrderPlacedEvent(event: unknown): event is OrderPlacedEvent { | |
| return ( | |
| typeof event === 'object' && | |
| event !== null && | |
| 'type' in event && | |
| (event as { type: unknown }).type === 'ORDER_PLACED' | |
| ); | |
| } | |
| // ✅ Readonly arrays — prevents accidental mutation in batch functions | |
| async function batchLoadUsers(ids: readonly string[]): Promise<(User | null)[]> { | |
| const users = await db.users.findManyByIds([...ids]); | |
| const map = new Map(users.map(u => [u.id, u])); | |
| return ids.map(id => map.get(id) ?? null); | |
| } | |
| ``` | |
| --- | |
| ## 10. TESTING RULES | |
| ``` | |
| Coverage targets (enforced in CI) | |
| ───────────────────────────────────────────────────────────────── | |
| Services → 90% line coverage (business logic = most valuable) | |
| Repositories → 80% (integration tests with test DB) | |
| Resolvers → 85% (unit tests with mocked context) | |
| Event producers → 85% | |
| Event consumers → 85% | |
| Adapters (Kafka/EventHub) → 70% (integration tests in CI) | |
| Utilities → 95% | |
| Test file rules | |
| ───────────────────────────────────────────────────────────────── | |
| ✅ Co-locate tests with source — order.service.test.ts next to order.service.ts | |
| ✅ One describe block per class or module | |
| ✅ One it() per behaviour — not per function | |
| ✅ Arrange / Act / Assert structure — one blank line between each section | |
| ✅ Test names describe behaviour: 'returns null when order does not exist' | |
| not 'findById test 1' | |
| ✅ Mock at the boundary — mock IOrderRepository, never the DB driver directly | |
| ✅ No logic in tests — if you need a loop in a test, write more tests | |
| ❌ No jest.setTimeout() — if a test needs it, the code under test is too slow | |
| ❌ No hardcoded IDs like '123' — use faker or descriptive constants | |
| // ✅ AAA structure — readable and consistent | |
| it('publishes OrderPlacedEvent after order is created', async () => { | |
| // Arrange | |
| const input = buildCreateOrderInput(); | |
| const mockEventBus = { publish: vi.fn() }; | |
| const service = new OrderService(mockRepo, mockEventBus); | |
| // Act | |
| await service.placeOrder(input); | |
| // Assert | |
| expect(mockEventBus.publish).toHaveBeenCalledOnce(); | |
| expect(mockEventBus.publish).toHaveBeenCalledWith( | |
| expect.objectContaining({ type: 'ORDER_PLACED' }), | |
| ); | |
| }); | |
| ``` | |
| --- | |
| ## 11. EVENTING-SPECIFIC RULES | |
| ``` | |
| Event contracts | |
| ───────────────────────────────────────────────────────────────── | |
| ✅ Every event extends BaseEvent { id, type, version, timestamp, source } | |
| ✅ Event names are past tense: OrderPlacedEvent, UserCreatedEvent | |
| ✅ Events are immutable value objects — no methods, no mutation after creation | |
| ✅ Bump event version field on any payload schema change | |
| ✅ Register all events in Azure Schema Registry / Confluent SR before publishing | |
| ❌ Never embed full entity state in events — only changed fields + entity ID | |
| Producers | |
| ───────────────────────────────────────────────────────────────── | |
| ✅ Producers are thin — they call eventBus.publish() and nothing else | |
| ✅ Publish after a successful DB write — never before | |
| ✅ Use the entity ID as the Kafka partition key for ordering guarantees | |
| ❌ Never publish from inside a repository — repositories are data access only | |
| Consumers | |
| ───────────────────────────────────────────────────────────────── | |
| ✅ Consumers are idempotent — processing the same event twice has no extra effect | |
| ✅ Consumers call the service layer — never the repository directly | |
| ✅ Commit offsets only after successful processing | |
| ✅ Route failures to the dead-letter topic — never silently drop messages | |
| ✅ Log the event ID, type, and partition offset on every message processed | |
| ❌ Never do blocking I/O inside a consumer without concurrency control | |
| ``` | |
| --- | |
| ## 12. QUICK REFERENCE CARD | |
| ``` | |
| ALWAYS NEVER | |
| ───────────────────────────────────────────────────────────────── | |
| Inject dependencies via constructor Instantiate inside a class | |
| Return early / guard clauses first Nest more than 3 levels deep | |
| Named exports Default exports | |
| Explicit return types any type | |
| Readonly where mutation is wrong Mutate function arguments | |
| Interfaces for all dependencies Concrete classes as parameters | |
| Past-tense event names Present/future-tense event names | |
| Publish events after DB writes Publish events before DB writes | |
| One class, one responsibility God classes / utility bags | |
| Test behaviour, not implementation Test private methods | |
| Delete dead code Comment out dead code | |
| TODO with ticket reference TODO without context | |
| ``` |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment