Skip to content

Instantly share code, notes, and snippets.

@daneuchar
Created March 11, 2026 06:09
Show Gist options
  • Select an option

  • Save daneuchar/860b9dadff89d9ee417dd032ac524f18 to your computer and use it in GitHub Desktop.

Select an option

Save daneuchar/860b9dadff89d9ee417dd032ac524f18 to your computer and use it in GitHub Desktop.
# 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