Solution Architecture Knowledge

49 min read
Solution Architecture Knowledge

Solution Architecture Interview Guide

Table of Contents

  1. Clean Architecture
  2. Clean Code Principles
  3. Test-Driven Development (TDD)
  4. Domain-Driven Design (DDD)
  5. Microservices Patterns
  6. Distributed Transactions
  7. Saga Pattern
  8. CQRS & Event Sourcing
  9. Other Important Patterns
  10. Authentication & Authorization

Clean Architecture

Overview

Clean Architecture, introduced by Robert C. Martin (Uncle Bob), is a software design philosophy that separates concerns into layers, making systems independent of frameworks, UI, databases, and external agencies.

Core Principles

  • Independence of Frameworks: Architecture doesn't depend on libraries
  • Testability: Business rules can be tested without UI, database, or external elements
  • Independence of UI: UI can change without changing the rest of the system
  • Independence of Database: Business rules are not bound to the database
  • Independence of External Agency: Business rules don't know anything about outside world

The Dependency Rule

Dependencies point inward: Source code dependencies must point only toward higher-level policies.

Layers (from outer to inner)

1. Frameworks & Drivers (Outermost)

  • Web frameworks
  • Database drivers
  • UI components
  • External interfaces

2. Interface Adapters

  • Controllers
  • Gateways
  • Presenters
  • View Models

3. Application Business Rules (Use Cases)

  • Application-specific business rules
  • Orchestrates data flow to/from entities
  • Directs entities to use their enterprise-wide business rules

4. Enterprise Business Rules (Entities - Innermost)

  • Enterprise-wide business rules
  • High-level rules that would exist even without the application
  • Pure business logic

Benefits

  • Maintainability: Easy to modify and extend
  • Testability: High test coverage possible
  • Flexibility: Easy to swap implementations
  • Scalability: Clear separation allows independent scaling

Interview Questions

  • Q: Explain the dependency rule in Clean Architecture

  • A: Dependencies must point inward. Outer layers can depend on inner layers, but inner layers cannot know about outer layers. This ensures business logic remains independent of implementation details.

  • Q: How does Clean Architecture handle database changes?

  • A: Database is treated as a detail in the outer layer. Business rules interact through interfaces (repositories), making it easy to swap database implementations without affecting core logic.


Clean Code Principles

Key Principles

1. Meaningful Names

// Bad
const d = 30; // days

// Good
const elapsedTimeInDays = 30;
const MAX_RETRY_ATTEMPTS = 3;

2. Functions Should Do One Thing

// Bad
function processUserDataAndSendEmail(user) {
  // validate user
  // save to database
  // send email
  // log activity
}

// Good
function validateUser(user) { }
function saveUser(user) { }
function sendWelcomeEmail(user) { }
function logUserActivity(user) { }

3. DRY (Don't Repeat Yourself)

  • Extract repeated logic into reusable functions
  • Use abstraction to eliminate duplication

4. Small Functions

  • Functions should be small (ideally < 20 lines)
  • Should have few parameters (ideally ≤ 3)

5. Comments

  • Code should be self-explanatory
  • Comments should explain "why", not "what"
  • Avoid redundant comments
// Bad
// Increment i
i++;

// Good
// Retry failed requests with exponential backoff
retryCount++;

6. Error Handling

  • Use exceptions rather than return codes
  • Don't return null (use Optional, Maybe, or throw exceptions)
  • Provide context with exceptions

7. Code Formatting

  • Consistent indentation
  • Proper spacing
  • Group related code together
  • Follow language conventions

SOLID Principles

S - Single Responsibility Principle (SRP)

A class should have one, and only one, reason to change.

// Bad
class User {
  saveToDatabase() { }
  sendEmail() { }
  generateReport() { }
}

// Good
class User { }
class UserRepository {
  save(user: User) { }
}
class EmailService {
  send(user: User) { }
}
class ReportGenerator {
  generate(user: User) { }
}

O - Open/Closed Principle (OCP)

Software entities should be open for extension, but closed for modification.

// Bad
class PaymentProcessor {
  processPayment(type: string) {
    if (type === 'credit') { }
    else if (type === 'paypal') { }
    // Need to modify when adding new payment types
  }
}

// Good
interface PaymentMethod {
  process(): void;
}

class CreditCardPayment implements PaymentMethod {
  process() { }
}

class PayPalPayment implements PaymentMethod {
  process() { }
}

L - Liskov Substitution Principle (LSP)

Objects of a superclass should be replaceable with objects of its subclasses without breaking the application.

I - Interface Segregation Principle (ISP)

No client should be forced to depend on methods it does not use.

// Bad
interface Worker {
  work(): void;
  eat(): void;
  sleep(): void;
}

// Good
interface Workable {
  work(): void;
}

interface Eatable {
  eat(): void;
}

interface Sleepable {
  sleep(): void;
}

D - Dependency Inversion Principle (DIP)

Depend upon abstractions, not concretions.

// Bad
class MySQLDatabase {
  save(data: any) { }
}

class UserService {
  private db = new MySQLDatabase();
  
  saveUser(user: User) {
    this.db.save(user);
  }
}

// Good
interface Database {
  save(data: any): void;
}

class MySQLDatabase implements Database {
  save(data: any) { }
}

class UserService {
  constructor(private db: Database) { }
  
  saveUser(user: User) {
    this.db.save(user);
  }
}

Test-Driven Development (TDD)

The Three Laws of TDD

  1. First Law: You may not write production code until you have written a failing unit test
  2. Second Law: You may not write more of a unit test than is sufficient to fail
  3. Third Law: You may not write more production code than is sufficient to pass the currently failing test

TDD Cycle (Red-Green-Refactor)

1. RED: Write a failing test
   ↓
2. GREEN: Write minimal code to pass the test
   ↓
3. REFACTOR: Clean up code while keeping tests green
   ↓
   Repeat

Benefits

  • Better Design: Forces you to think about API before implementation
  • Documentation: Tests serve as living documentation
  • Confidence: Safe refactoring with comprehensive test coverage
  • Fewer Bugs: Issues caught early in development
  • Faster Development: Less debugging time in the long run

TDD Best Practices

1. Test One Thing at a Time

// Bad
test('user operations', () => {
  expect(createUser()).toBeTruthy();
  expect(updateUser()).toBeTruthy();
  expect(deleteUser()).toBeTruthy();
});

// Good
test('should create user', () => {
  expect(createUser()).toBeTruthy();
});

test('should update user', () => {
  expect(updateUser()).toBeTruthy();
});

2. Follow AAA Pattern

  • Arrange: Set up test data
  • Act: Execute the code under test
  • Assert: Verify the results
test('should calculate total price with discount', () => {
  // Arrange
  const items = [{ price: 100 }, { price: 200 }];
  const discount = 0.1;
  
  // Act
  const total = calculateTotal(items, discount);
  
  // Assert
  expect(total).toBe(270);
});

3. Test Behavior, Not Implementation

// Bad - Testing implementation
test('should call database.save', () => {
  const spy = jest.spyOn(database, 'save');
  userService.createUser(user);
  expect(spy).toHaveBeenCalled();
});

// Good - Testing behavior
test('should persist user', async () => {
  const user = await userService.createUser(userData);
  const savedUser = await userService.findById(user.id);
  expect(savedUser).toEqual(user);
});

Testing Pyramid

       /\
      /UI\        ← Few (E2E Tests)
     /____\
    /      \
   /Integration\  ← Some (Integration Tests)
  /____________\
 /              \
/  Unit Tests    \ ← Many (Unit Tests)
/________________\

Domain-Driven Design (DDD)

Core Concepts

1. Ubiquitous Language

A common language shared by developers and domain experts

  • Use domain terminology in code
  • Same terms in conversations, documentation, and code

2. Bounded Context

A boundary within which a particular domain model is defined and applicable

┌─────────────────┐    ┌─────────────────┐
│  Order Context  │    │  Shipping       │
│                 │    │  Context        │
│  Order          │───▶│  Shipment       │
│  OrderItem      │    │  Package        │
│  Customer       │    │  Address        │
└─────────────────┘    └─────────────────┘

3. Entities

Objects with a distinct identity that runs through time and different states

class Order {
  constructor(
    private readonly id: OrderId,
    private status: OrderStatus,
    private items: OrderItem[]
  ) {}
  
  // Identity is based on id, not on properties
  equals(other: Order): boolean {
    return this.id.equals(other.id);
  }
}

4. Value Objects

Objects that describe characteristics but have no identity

class Money {
  constructor(
    private readonly amount: number,
    private readonly currency: string
  ) {}
  
  // Equality based on values
  equals(other: Money): boolean {
    return this.amount === other.amount && 
           this.currency === other.currency;
  }
  
  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new Error('Cannot add different currencies');
    }
    return new Money(this.amount + other.amount, this.currency);
  }
}

5. Aggregates

A cluster of domain objects that can be treated as a single unit

// Order is the Aggregate Root
class Order {
  private items: OrderItem[] = [];
  
  addItem(item: OrderItem) {
    // Validate business rules
    if (this.status !== OrderStatus.DRAFT) {
      throw new Error('Cannot modify confirmed order');
    }
    this.items.push(item);
  }
  
  // Only Order can modify OrderItems
  // External code cannot directly access items
}

Aggregate Rules:

  • One Aggregate Root per aggregate
  • References from outside use the root only
  • Transaction boundaries align with aggregates

6. Repositories

Abstracts persistence, provides collection-like interface

interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  save(order: Order): Promise<void>;
  findByCustomer(customerId: CustomerId): Promise<Order[]>;
}

7. Domain Events

Something that happened in the domain that domain experts care about

class OrderPlacedEvent {
  constructor(
    public readonly orderId: OrderId,
    public readonly customerId: CustomerId,
    public readonly occurredAt: Date
  ) {}
}

class Order {
  place(): void {
    // Business logic
    this.status = OrderStatus.PLACED;
    
    // Publish event
    this.addDomainEvent(
      new OrderPlacedEvent(this.id, this.customerId, new Date())
    );
  }
}

8. Domain Services

Operations that don't belong to a specific entity or value object

class PricingService {
  calculateOrderTotal(order: Order, customer: Customer): Money {
    const discount = customer.isPremium() ? 0.1 : 0;
    return order.subtotal().applyDiscount(discount);
  }
}

9. Application Services

Orchestrates domain objects, handles use cases

class PlaceOrderService {
  constructor(
    private orderRepository: OrderRepository,
    private eventBus: EventBus
  ) {}
  
  async execute(command: PlaceOrderCommand): Promise<void> {
    const order = await this.orderRepository.findById(command.orderId);
    
    order.place(); // Domain logic
    
    await this.orderRepository.save(order);
    
    // Publish events
    order.domainEvents.forEach(event => this.eventBus.publish(event));
  }
}

Strategic Design Patterns

Context Mapping

Describes relationships between bounded contexts:

  • Shared Kernel: Two contexts share a subset of the domain model
  • Customer/Supplier: Upstream context provides API for downstream
  • Conformist: Downstream context conforms to upstream model
  • Anticorruption Layer: Translation layer to prevent upstream changes affecting downstream
  • Separate Ways: Contexts are completely independent

Microservices Patterns

Core Characteristics

  1. Independently Deployable: Each service can be deployed without affecting others
  2. Organized Around Business Capabilities: Services align with business domains
  3. Decentralized Data Management: Each service owns its data
  4. Infrastructure Automation: CI/CD, automated testing
  5. Design for Failure: Handle partial failures gracefully

Decomposition Patterns

1. Decompose by Business Capability

┌──────────────┐  ┌──────────────┐  ┌──────────────┐
│   Order      │  │   Inventory  │  │   Shipping   │
│   Service    │  │   Service    │  │   Service    │
└──────────────┘  └──────────────┘  └──────────────┘

2. Decompose by Subdomain (DDD)

Align microservices with DDD bounded contexts

3. Strangler Fig Pattern

Gradually replace legacy system by putting new services around the edges

Communication Patterns

1. API Gateway

Single entry point for clients

        Client
           ↓
    ┌─────────────┐
    │ API Gateway │
    └─────────────┘
     ↓    ↓    ↓
   ┌──┐ ┌──┐ ┌──┐
   │S1│ │S2│ │S3│
   └──┘ └──┘ └──┘

Benefits:

  • Simplified client code
  • Request routing and composition
  • Authentication and authorization
  • Rate limiting

2. Backend for Frontend (BFF)

Separate gateway for each client type

Mobile App  →  [Mobile BFF]  →  ┐
Web App     →  [Web BFF]     →  ├→ Microservices
Desktop App →  [Desktop BFF] →  ┘

3. Service Mesh

Infrastructure layer for service-to-service communication

Features:

  • Service discovery
  • Load balancing
  • Encryption
  • Authentication
  • Monitoring

Data Patterns

1. Database per Service

Each microservice has its own database

// Order Service
class OrderService {
  private orderDB: Database; // Orders database
}

// Customer Service  
class CustomerService {
  private customerDB: Database; // Customers database
}

Benefits:

  • Loose coupling
  • Technology diversity
  • Independent scaling

Challenges:

  • Data consistency
  • Queries across services
  • Data duplication

2. Shared Database (Anti-pattern)

Multiple services sharing one database - generally avoided

3. CQRS (Command Query Responsibility Segregation)

Separate models for read and write operations

// Write Model (Commands)
class OrderCommandService {
  async placeOrder(command: PlaceOrderCommand) {
    // Write to normalized relational DB
  }
}

// Read Model (Queries)
class OrderQueryService {
  async getOrderDetails(orderId: string) {
    // Read from denormalized view/cache
  }
}

4. Event Sourcing

Store state changes as sequence of events

// Instead of storing current state
{ orderId: 1, status: 'SHIPPED', total: 100 }

// Store events
[
  { type: 'OrderPlaced', orderId: 1, total: 100 },
  { type: 'OrderPaid', orderId: 1 },
  { type: 'OrderShipped', orderId: 1 }
]

Resilience Patterns

1. Circuit Breaker

Prevent cascading failures

class CircuitBreaker {
  private state = 'CLOSED';
  private failureCount = 0;
  
  async call(fn: Function) {
    if (this.state === 'OPEN') {
      throw new Error('Circuit breaker is OPEN');
    }
    
    try {
      const result = await fn();
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }
  
  private onFailure() {
    this.failureCount++;
    if (this.failureCount >= THRESHOLD) {
      this.state = 'OPEN';
      setTimeout(() => this.state = 'HALF_OPEN', TIMEOUT);
    }
  }
}

States:

  • CLOSED: Normal operation
  • OPEN: Fail fast, don't call service
  • HALF_OPEN: Try again after timeout

2. Retry Pattern

Retry failed operations with backoff

async function retryWithBackoff(fn: Function, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await fn();
    } catch (error) {
      if (i === maxRetries - 1) throw error;
      await sleep(Math.pow(2, i) * 1000); // Exponential backoff
    }
  }
}

3. Bulkhead Pattern

Isolate resources to prevent total failure

// Separate thread pools for different services
const orderServicePool = new ThreadPool(10);
const paymentServicePool = new ThreadPool(10);

// If payment service fails, order service still has resources

4. Timeout Pattern

Prevent indefinite waiting

async function withTimeout(promise: Promise<any>, ms: number) {
  const timeout = new Promise((_, reject) => 
    setTimeout(() => reject(new Error('Timeout')), ms)
  );
  return Promise.race([promise, timeout]);
}

Observability Patterns

1. Health Check API

app.get('/health', (req, res) => {
  const health = {
    status: 'UP',
    database: checkDatabase(),
    cache: checkCache(),
    dependencies: checkDependencies()
  };
  res.json(health);
});

2. Distributed Tracing

Track requests across services

Request ID: abc-123
Service A (10ms) → Service B (20ms) → Service C (15ms)
Total: 45ms

3. Log Aggregation

Centralize logs from all services


Distributed Transactions

The Challenge

In distributed systems, maintaining data consistency across multiple services/databases is difficult because:

  • Services have independent databases
  • Network can fail
  • Services can fail
  • Partial failures occur

ACID vs BASE

ACID (Traditional Databases)

  • Atomicity: All or nothing
  • Consistency: Valid state transitions
  • Isolation: Concurrent transactions don't interfere
  • Durability: Committed data persists

BASE (Distributed Systems)

  • Basically Available: System appears to work most of the time
  • Soft state: State may change over time without input
  • Eventual consistency: System will become consistent eventually

Two-Phase Commit (2PC)

A distributed transaction protocol for achieving atomic commits

Phases:

Phase 1: Prepare

Coordinator                Participants
    |                           |
    |----Prepare Request------->|
    |                           |
    |<------Vote Yes/No---------|

Phase 2: Commit/Abort

Coordinator                Participants
    |                           |
    |----Commit/Abort---------->|
    |                           |
    |<------Acknowledgment------|

Problems:

  • Blocking: Participants wait for coordinator
  • Single point of failure: Coordinator fails
  • Performance: Requires locks during transaction
  • Not suitable for microservices: Tight coupling

Three-Phase Commit (3PC)

Adds a third phase to address 2PC blocking issue

  • Still has performance problems
  • Rarely used in practice

Saga Pattern

Overview

A saga is a sequence of local transactions where each transaction updates data within a single service and publishes an event/message to trigger the next step. If a step fails, the saga executes compensating transactions to undo changes.

Types of Sagas

1. Choreography-Based Saga

Services communicate through events without central coordination

Order Service    Payment Service    Inventory Service
     |                  |                   |
     |--OrderCreated--->|                   |
     |                  |                   |
     |                  |--PaymentProcessed->|
     |                  |                   |
     |<-----------InventoryReserved---------|
     |                  |                   |

Advantages:

  • Loose coupling
  • Simple for basic workflows
  • Good for event-driven architectures

Disadvantages:

  • Hard to understand workflow
  • Difficult to debug
  • Risk of cyclic dependencies

Example:

// Order Service
class OrderService {
  async createOrder(orderData: CreateOrderDTO) {
    const order = await this.orderRepo.save(orderData);
    
    // Publish event
    await this.eventBus.publish(new OrderCreatedEvent(order));
    
    return order;
  }
  
  @EventHandler(PaymentFailedEvent)
  async onPaymentFailed(event: PaymentFailedEvent) {
    // Compensating transaction
    await this.orderRepo.updateStatus(event.orderId, 'CANCELLED');
  }
}

// Payment Service
class PaymentService {
  @EventHandler(OrderCreatedEvent)
  async onOrderCreated(event: OrderCreatedEvent) {
    try {
      await this.processPayment(event.order);
      await this.eventBus.publish(new PaymentCompletedEvent(event.orderId));
    } catch (error) {
      await this.eventBus.publish(new PaymentFailedEvent(event.orderId));
    }
  }
}

2. Orchestration-Based Saga

Central orchestrator directs the saga

                Saga Orchestrator
                       |
        ┌──────────────┼──────────────┐
        ↓              ↓              ↓
   Order Service  Payment Service  Inventory Service

Advantages:

  • Centralized workflow logic
  • Easier to understand and test
  • Better for complex workflows
  • Explicit saga state

Disadvantages:

  • Orchestrator can become complex
  • Additional service to maintain

Example:

class OrderSagaOrchestrator {
  async execute(orderData: CreateOrderDTO) {
    const saga = new OrderSaga();
    
    try {
      // Step 1: Create Order
      const order = await this.orderService.createOrder(orderData);
      saga.setOrderId(order.id);
      
      // Step 2: Process Payment
      await this.paymentService.processPayment(order.id, order.total);
      saga.setPaymentCompleted();
      
      // Step 3: Reserve Inventory
      await this.inventoryService.reserveItems(order.items);
      saga.setInventoryReserved();
      
      // Step 4: Confirm Order
      await this.orderService.confirmOrder(order.id);
      
      return order;
      
    } catch (error) {
      // Compensating transactions in reverse order
      await this.compensate(saga);
      throw error;
    }
  }
  
  private async compensate(saga: OrderSaga) {
    if (saga.inventoryReserved) {
      await this.inventoryService.releaseItems(saga.orderId);
    }
    
    if (saga.paymentCompleted) {
      await this.paymentService.refund(saga.orderId);
    }
    
    if (saga.orderId) {
      await this.orderService.cancelOrder(saga.orderId);
    }
  }
}

Compensating Transactions

Saga compensations must be:

  1. Idempotent: Can be safely retried
  2. Retryable: Will eventually succeed
  3. Reversible: Undo previous step
// Example: Payment Compensation
class PaymentService {
  async refundPayment(orderId: string) {
    const payment = await this.findByOrderId(orderId);
    
    // Idempotent check
    if (payment.status === 'REFUNDED') {
      return; // Already refunded
    }
    
    try {
      await this.paymentGateway.refund(payment.transactionId);
      payment.status = 'REFUNDED';
      await this.save(payment);
    } catch (error) {
      // Log and retry later
      await this.retryQueue.add({ orderId, action: 'refund' });
      throw error;
    }
  }
}

Saga Execution Coordinator (SEC)

For orchestration-based sagas, use a durable execution coordinator:

interface SagaStep {
  execute(): Promise<void>;
  compensate(): Promise<void>;
}

class SagaExecutor {
  private steps: SagaStep[] = [];
  private executedSteps: SagaStep[] = [];
  
  addStep(step: SagaStep) {
    this.steps.push(step);
    return this;
  }
  
  async execute() {
    for (const step of this.steps) {
      try {
        await step.execute();
        this.executedSteps.push(step);
        await this.saveSagaState(); // Persist progress
      } catch (error) {
        await this.rollback();
        throw error;
      }
    }
  }
  
  private async rollback() {
    // Execute compensations in reverse order
    for (const step of this.executedSteps.reverse()) {
      await step.compensate();
    }
  }
}

Handling Failures

1. Backward Recovery (Compensating)

Undo completed steps

2. Forward Recovery (Retry)

Retry failed step until it succeeds

class SagaStep {
  async executeWithRetry(maxRetries = 3) {
    for (let i = 0; i < maxRetries; i++) {
      try {
        return await this.execute();
      } catch (error) {
        if (i === maxRetries - 1) {
          throw error; // Failed all retries, trigger compensation
        }
        await sleep(Math.pow(2, i) * 1000);
      }
    }
  }
}

Saga Persistence

Store saga state to handle failures:

interface SagaState {
  id: string;
  status: 'STARTED' | 'COMPLETED' | 'FAILED' | 'COMPENSATING';
  currentStep: number;
  data: any;
  completedSteps: string[];
  createdAt: Date;
  updatedAt: Date;
}

class SagaStateRepository {
  async save(state: SagaState): Promise<void> {
    // Persist to database
  }
  
  async recover(sagaId: string): Promise<SagaState> {
    // Recover saga state after failure
  }
}

CQRS & Event Sourcing

CQRS (Command Query Responsibility Segregation)

Overview

CQRS separates read and write operations into different models. Instead of using the same data model for both queries and commands, you use separate models optimized for each operation type.

Traditional CRUD:
┌─────────────┐
│   Single    │
│   Model     │◄──── Read & Write
└─────────────┘

CQRS:
┌─────────────┐          ┌─────────────┐
│  Command    │          │   Query     │
│   Model     │          │   Model     │
│  (Write)    │          │   (Read)    │
└─────────────┘          └─────────────┘
       │                        ▲
       │    Synchronization     │
       └────────────────────────┘

Why CQRS?

ProblemCQRS Solution
Read/Write ratio imbalanceScale reads independently
Complex queries slow down writesOptimized read models
Different validation for R/WSeparate models with different rules
Conflicting requirementsEach model optimized for its purpose

CQRS Components

Commands (Write Side)
// Commands represent intent to change state
interface Command {
  readonly type: string;
  readonly timestamp: Date;
}

class CreateOrderCommand implements Command {
  readonly type = 'CreateOrder';
  readonly timestamp = new Date();
  
  constructor(
    public readonly customerId: string,
    public readonly items: OrderItem[],
    public readonly shippingAddress: Address
  ) {}
}

class CancelOrderCommand implements Command {
  readonly type = 'CancelOrder';
  readonly timestamp = new Date();
  
  constructor(
    public readonly orderId: string,
    public readonly reason: string
  ) {}
}
Command Handlers
interface CommandHandler<T extends Command> {
  handle(command: T): Promise<void>;
}

class CreateOrderHandler implements CommandHandler<CreateOrderCommand> {
  constructor(
    private orderRepository: OrderRepository,
    private eventBus: EventBus
  ) {}
  
  async handle(command: CreateOrderCommand): Promise<void> {
    // Validate business rules
    await this.validateCustomer(command.customerId);
    await this.validateItems(command.items);
    
    // Create aggregate
    const order = Order.create(
      command.customerId,
      command.items,
      command.shippingAddress
    );
    
    // Persist
    await this.orderRepository.save(order);
    
    // Publish events for read model updates
    await this.eventBus.publishAll(order.domainEvents);
  }
}
Queries (Read Side)
// Queries represent requests for data
interface Query<T> {
  readonly type: string;
}

class GetOrderDetailsQuery implements Query<OrderDetailsDTO> {
  readonly type = 'GetOrderDetails';
  
  constructor(public readonly orderId: string) {}
}

class GetCustomerOrdersQuery implements Query<OrderSummaryDTO[]> {
  readonly type = 'GetCustomerOrders';
  
  constructor(
    public readonly customerId: string,
    public readonly page: number,
    public readonly pageSize: number
  ) {}
}
Query Handlers
interface QueryHandler<Q extends Query<R>, R> {
  handle(query: Q): Promise<R>;
}

class GetOrderDetailsHandler implements QueryHandler<GetOrderDetailsQuery, OrderDetailsDTO> {
  constructor(private readDb: ReadDatabase) {}
  
  async handle(query: GetOrderDetailsQuery): Promise<OrderDetailsDTO> {
    // Read from optimized read model (denormalized view)
    return await this.readDb.orderDetails.findOne({
      orderId: query.orderId
    });
  }
}

CQRS Data Synchronization

Option 1: Synchronous (Same Database)
class OrderCommandHandler {
  async handle(command: CreateOrderCommand) {
    await this.writeDb.transaction(async (tx) => {
      // Write to normalized tables
      await tx.orders.insert(order);
      await tx.orderItems.insertMany(items);
      
      // Update denormalized read model in same transaction
      await tx.orderReadModel.insert({
        orderId: order.id,
        customerName: customer.name,
        totalAmount: order.total,
        itemCount: items.length,
        status: order.status
      });
    });
  }
}
Option 2: Asynchronous (Event-Driven)
// Write side publishes events
class OrderCommandHandler {
  async handle(command: CreateOrderCommand) {
    const order = Order.create(...);
    await this.orderRepository.save(order);
    
    // Publish event
    await this.eventBus.publish(new OrderCreatedEvent(order));
  }
}

// Read side subscribes to events
class OrderReadModelProjection {
  @EventHandler(OrderCreatedEvent)
  async onOrderCreated(event: OrderCreatedEvent) {
    await this.readDb.orderReadModel.insert({
      orderId: event.order.id,
      customerName: event.order.customerName,
      totalAmount: event.order.total,
      itemCount: event.order.items.length,
      status: 'CREATED',
      createdAt: event.occurredAt
    });
  }
  
  @EventHandler(OrderShippedEvent)
  async onOrderShipped(event: OrderShippedEvent) {
    await this.readDb.orderReadModel.update(
      { orderId: event.orderId },
      { status: 'SHIPPED', shippedAt: event.occurredAt }
    );
  }
}
Option 3: Different Databases
Write Model (PostgreSQL)     Read Model (Elasticsearch)
┌──────────────────────┐     ┌──────────────────────┐
│ orders               │     │ order_search_index   │
│ order_items          │────>│ (denormalized,       │
│ customers            │     │  optimized for       │
│ (normalized)         │     │  full-text search)   │
└──────────────────────┘     └──────────────────────┘
         │                              ▲
         │     Event Bus / CDC          │
         └──────────────────────────────┘

CQRS Consistency

ApproachConsistencyComplexityUse Case
Same DB, same transactionStrongLowSimple apps
Same DB, async updateEventualMediumModerate load
Different DBsEventualHighHigh scalability
Handling Eventual Consistency
// Client-side: Optimistic UI
class OrderService {
  async createOrder(data: CreateOrderDTO): Promise<OrderDTO> {
    // Create order
    const orderId = await this.commandBus.execute(
      new CreateOrderCommand(data)
    );
    
    // Return immediately with known data
    // (read model might not be updated yet)
    return {
      id: orderId,
      status: 'PENDING',
      items: data.items,
      // ... data we already know
    };
  }
}

// Polling for updates
async function waitForReadModel(orderId: string, maxWait = 5000) {
  const start = Date.now();
  
  while (Date.now() - start < maxWait) {
    const order = await queryBus.execute(new GetOrderQuery(orderId));
    if (order) return order;
    await sleep(100);
  }
  
  throw new Error('Read model not updated in time');
}

When to Use CQRS

Good Fit:

  • High read/write ratio disparity
  • Complex queries requiring joins across aggregates
  • Different scaling requirements for reads and writes
  • Event-driven architectures
  • Systems with multiple read representations

Avoid When:

  • Simple CRUD applications
  • Small teams with limited resources
  • Strong consistency is critical everywhere
  • Low traffic systems

Event Sourcing

Overview

Event Sourcing stores the state of an entity as a sequence of events rather than the current state. The current state is derived by replaying all events.

Traditional (State-Based):
┌─────────────────────────────────┐
│ Order #123                       │
│ status: SHIPPED                  │
│ total: $150                      │
│ updated_at: 2024-01-15          │
└─────────────────────────────────┘

Event Sourcing (Event-Based):
┌─────────────────────────────────┐
│ Event 1: OrderCreated           │
│   orderId: 123, total: $150     │
├─────────────────────────────────┤
│ Event 2: PaymentReceived        │
│   orderId: 123, amount: $150    │
├─────────────────────────────────┤
│ Event 3: OrderShipped           │
│   orderId: 123, trackingNo: X   │
└─────────────────────────────────┘
       ↓ Replay events
Current State: { status: SHIPPED, total: $150, ... }

Core Concepts

Events
// Events are immutable facts that happened
interface DomainEvent {
  readonly eventId: string;
  readonly aggregateId: string;
  readonly aggregateType: string;
  readonly eventType: string;
  readonly version: number;
  readonly occurredAt: Date;
  readonly data: unknown;
}

class OrderCreatedEvent implements DomainEvent {
  readonly eventType = 'OrderCreated';
  
  constructor(
    public readonly eventId: string,
    public readonly aggregateId: string,
    public readonly version: number,
    public readonly occurredAt: Date,
    public readonly data: {
      customerId: string;
      items: OrderItem[];
      totalAmount: number;
    }
  ) {}
}

class OrderItemAddedEvent implements DomainEvent {
  readonly eventType = 'OrderItemAdded';
  // ...
}

class OrderCancelledEvent implements DomainEvent {
  readonly eventType = 'OrderCancelled';
  // ...
}
Event Store
interface EventStore {
  // Append events for an aggregate
  append(
    aggregateId: string,
    events: DomainEvent[],
    expectedVersion: number
  ): Promise<void>;
  
  // Load all events for an aggregate
  loadEvents(aggregateId: string): Promise<DomainEvent[]>;
  
  // Load events from a specific version
  loadEventsFromVersion(
    aggregateId: string,
    fromVersion: number
  ): Promise<DomainEvent[]>;
  
  // Subscribe to all events (for projections)
  subscribe(handler: (event: DomainEvent) => Promise<void>): void;
}

// PostgreSQL Implementation
class PostgresEventStore implements EventStore {
  async append(
    aggregateId: string,
    events: DomainEvent[],
    expectedVersion: number
  ): Promise<void> {
    await this.db.transaction(async (tx) => {
      // Optimistic concurrency check
      const currentVersion = await tx.query(
        'SELECT MAX(version) FROM events WHERE aggregate_id = $1',
        [aggregateId]
      );
      
      if (currentVersion !== expectedVersion) {
        throw new ConcurrencyError(
          `Expected version ${expectedVersion}, found ${currentVersion}`
        );
      }
      
      // Append events
      for (const event of events) {
        await tx.query(
          `INSERT INTO events 
           (event_id, aggregate_id, event_type, version, data, occurred_at)
           VALUES ($1, $2, $3, $4, $5, $6)`,
          [event.eventId, aggregateId, event.eventType, 
           event.version, JSON.stringify(event.data), event.occurredAt]
        );
      }
    });
  }
  
  async loadEvents(aggregateId: string): Promise<DomainEvent[]> {
    const rows = await this.db.query(
      'SELECT * FROM events WHERE aggregate_id = $1 ORDER BY version',
      [aggregateId]
    );
    return rows.map(this.deserializeEvent);
  }
}
Event-Sourced Aggregate
abstract class EventSourcedAggregate {
  private uncommittedEvents: DomainEvent[] = [];
  protected version: number = 0;
  
  // Apply event and update state
  protected apply(event: DomainEvent): void {
    this.when(event);
    this.version = event.version;
    this.uncommittedEvents.push(event);
  }
  
  // State mutation based on event type
  protected abstract when(event: DomainEvent): void;
  
  // Get events to persist
  getUncommittedEvents(): DomainEvent[] {
    return [...this.uncommittedEvents];
  }
  
  // Clear after persistence
  clearUncommittedEvents(): void {
    this.uncommittedEvents = [];
  }
  
  // Reconstitute from events
  static rehydrate<T extends EventSourcedAggregate>(
    events: DomainEvent[],
    factory: () => T
  ): T {
    const aggregate = factory();
    events.forEach(event => {
      aggregate.when(event);
      aggregate.version = event.version;
    });
    return aggregate;
  }
}

class Order extends EventSourcedAggregate {
  private id: string;
  private status: OrderStatus;
  private items: OrderItem[] = [];
  private totalAmount: number = 0;
  
  // Command methods create events
  static create(customerId: string, items: OrderItem[]): Order {
    const order = new Order();
    const total = items.reduce((sum, item) => sum + item.price, 0);
    
    order.apply(new OrderCreatedEvent(
      uuid(),
      uuid(), // aggregateId
      1,      // version
      new Date(),
      { customerId, items, totalAmount: total }
    ));
    
    return order;
  }
  
  addItem(item: OrderItem): void {
    if (this.status !== OrderStatus.DRAFT) {
      throw new Error('Cannot add items to non-draft order');
    }
    
    this.apply(new OrderItemAddedEvent(
      uuid(),
      this.id,
      this.version + 1,
      new Date(),
      { item }
    ));
  }
  
  cancel(reason: string): void {
    if (this.status === OrderStatus.SHIPPED) {
      throw new Error('Cannot cancel shipped order');
    }
    
    this.apply(new OrderCancelledEvent(
      uuid(),
      this.id,
      this.version + 1,
      new Date(),
      { reason }
    ));
  }
  
  // State mutations
  protected when(event: DomainEvent): void {
    switch (event.eventType) {
      case 'OrderCreated':
        this.id = event.aggregateId;
        this.status = OrderStatus.DRAFT;
        this.items = event.data.items;
        this.totalAmount = event.data.totalAmount;
        break;
        
      case 'OrderItemAdded':
        this.items.push(event.data.item);
        this.totalAmount += event.data.item.price;
        break;
        
      case 'OrderCancelled':
        this.status = OrderStatus.CANCELLED;
        break;
    }
  }
}
Repository with Event Sourcing
class EventSourcedOrderRepository {
  constructor(private eventStore: EventStore) {}
  
  async findById(orderId: string): Promise<Order | null> {
    const events = await this.eventStore.loadEvents(orderId);
    
    if (events.length === 0) {
      return null;
    }
    
    return Order.rehydrate(events, () => new Order());
  }
  
  async save(order: Order): Promise<void> {
    const events = order.getUncommittedEvents();
    
    if (events.length === 0) {
      return;
    }
    
    const expectedVersion = order.version - events.length;
    
    await this.eventStore.append(
      order.id,
      events,
      expectedVersion
    );
    
    order.clearUncommittedEvents();
  }
}

Projections (Read Models)

Projections build read-optimized views from events:

// Projection for order list view
class OrderListProjection {
  constructor(
    private readDb: Database,
    private eventStore: EventStore
  ) {}
  
  async start(): Promise<void> {
    // Subscribe to event stream
    this.eventStore.subscribe(async (event) => {
      await this.project(event);
    });
  }
  
  private async project(event: DomainEvent): Promise<void> {
    switch (event.eventType) {
      case 'OrderCreated':
        await this.readDb.orderList.insert({
          orderId: event.aggregateId,
          customerId: event.data.customerId,
          totalAmount: event.data.totalAmount,
          itemCount: event.data.items.length,
          status: 'DRAFT',
          createdAt: event.occurredAt
        });
        break;
        
      case 'OrderShipped':
        await this.readDb.orderList.update(
          { orderId: event.aggregateId },
          { status: 'SHIPPED', shippedAt: event.occurredAt }
        );
        break;
        
      case 'OrderCancelled':
        await this.readDb.orderList.update(
          { orderId: event.aggregateId },
          { status: 'CANCELLED' }
        );
        break;
    }
  }
  
  // Rebuild projection from scratch
  async rebuild(): Promise<void> {
    await this.readDb.orderList.deleteAll();
    
    const allEvents = await this.eventStore.loadAllEvents();
    for (const event of allEvents) {
      await this.project(event);
    }
  }
}

// Multiple projections from same events
class OrderAnalyticsProjection {
  async project(event: DomainEvent): Promise<void> {
    if (event.eventType === 'OrderCreated') {
      await this.analyticsDb.dailyOrders.increment(
        event.occurredAt.toDateString(),
        event.data.totalAmount
      );
    }
  }
}

Snapshots

For aggregates with many events, use snapshots to improve load performance:

interface Snapshot {
  aggregateId: string;
  version: number;
  state: unknown;
  createdAt: Date;
}

class SnapshotStore {
  async save(snapshot: Snapshot): Promise<void> {
    await this.db.snapshots.upsert(snapshot);
  }
  
  async load(aggregateId: string): Promise<Snapshot | null> {
    return this.db.snapshots.findOne({ aggregateId });
  }
}

class EventSourcedOrderRepository {
  constructor(
    private eventStore: EventStore,
    private snapshotStore: SnapshotStore,
    private snapshotFrequency: number = 100
  ) {}
  
  async findById(orderId: string): Promise<Order | null> {
    // Try to load snapshot first
    const snapshot = await this.snapshotStore.load(orderId);
    
    let events: DomainEvent[];
    let order: Order;
    
    if (snapshot) {
      // Load only events after snapshot
      events = await this.eventStore.loadEventsFromVersion(
        orderId,
        snapshot.version + 1
      );
      order = Order.fromSnapshot(snapshot.state);
    } else {
      // Load all events
      events = await this.eventStore.loadEvents(orderId);
      order = new Order();
    }
    
    // Apply remaining events
    events.forEach(event => order.applyFromHistory(event));
    
    return order;
  }
  
  async save(order: Order): Promise<void> {
    const events = order.getUncommittedEvents();
    await this.eventStore.append(order.id, events, order.version - events.length);
    
    // Create snapshot if needed
    if (order.version % this.snapshotFrequency === 0) {
      await this.snapshotStore.save({
        aggregateId: order.id,
        version: order.version,
        state: order.toSnapshot(),
        createdAt: new Date()
      });
    }
    
    order.clearUncommittedEvents();
  }
}

Event Versioning & Migration

Handle schema evolution:

// Event Upcasting - transform old events to new format
interface EventUpcaster {
  eventType: string;
  fromVersion: number;
  toVersion: number;
  upcast(event: DomainEvent): DomainEvent;
}

class OrderCreatedV1ToV2Upcaster implements EventUpcaster {
  eventType = 'OrderCreated';
  fromVersion = 1;
  toVersion = 2;
  
  upcast(event: DomainEvent): DomainEvent {
    // V1 had 'amount', V2 has 'totalAmount' and 'currency'
    return {
      ...event,
      schemaVersion: 2,
      data: {
        ...event.data,
        totalAmount: event.data.amount,
        currency: 'USD', // Default for old events
      }
    };
  }
}

class EventUpcasterChain {
  private upcasters: Map<string, EventUpcaster[]> = new Map();
  
  register(upcaster: EventUpcaster): void {
    const key = upcaster.eventType;
    const existing = this.upcasters.get(key) || [];
    existing.push(upcaster);
    existing.sort((a, b) => a.fromVersion - b.fromVersion);
    this.upcasters.set(key, existing);
  }
  
  upcast(event: DomainEvent, targetVersion: number): DomainEvent {
    const upcasters = this.upcasters.get(event.eventType) || [];
    let current = event;
    
    for (const upcaster of upcasters) {
      if (current.schemaVersion >= targetVersion) break;
      if (current.schemaVersion === upcaster.fromVersion) {
        current = upcaster.upcast(current);
      }
    }
    
    return current;
  }
}

Event Sourcing + CQRS Combined

┌─────────────────────────────────────────────────────────────┐
│                        WRITE SIDE                           │
│  ┌──────────┐    ┌──────────┐    ┌─────────────────────┐   │
│  │ Command  │───>│ Aggregate│───>│    Event Store      │   │
│  │ Handler  │    │          │    │ (append-only log)   │   │
│  └──────────┘    └──────────┘    └──────────┬──────────┘   │
└──────────────────────────────────────────────┼──────────────┘
                                               │
                                     Event Bus │
                                               │
┌──────────────────────────────────────────────┼──────────────┐
│                        READ SIDE             ▼              │
│  ┌───────────────┐    ┌───────────────────────────────┐    │
│  │ Query Handler │◄───│      Read Database            │    │
│  └───────────────┘    │  (Projections/Views)          │    │
│                       │  ┌─────────┐ ┌─────────────┐  │    │
│                       │  │Order    │ │Order        │  │    │
│                       │  │List     │ │Analytics    │  │    │
│                       │  │View     │ │Dashboard    │  │    │
│                       │  └─────────┘ └─────────────┘  │    │
│                       └───────────────────────────────┘    │
└────────────────────────────────────────────────────────────┘
// Complete flow example
class OrderApplicationService {
  constructor(
    private orderRepository: EventSourcedOrderRepository,
    private eventBus: EventBus
  ) {}
  
  async createOrder(command: CreateOrderCommand): Promise<string> {
    // Create aggregate (generates events internally)
    const order = Order.create(
      command.customerId,
      command.items
    );
    
    // Persist events to event store
    await this.orderRepository.save(order);
    
    // Publish events for projections
    for (const event of order.getUncommittedEvents()) {
      await this.eventBus.publish(event);
    }
    
    return order.id;
  }
}

// Projections update read models
class ReadModelUpdater {
  @EventHandler(OrderCreatedEvent)
  async handle(event: OrderCreatedEvent) {
    // Update multiple read models
    await Promise.all([
      this.orderListProjection.project(event),
      this.customerOrdersProjection.project(event),
      this.analyticsProjection.project(event)
    ]);
  }
}

Benefits of Event Sourcing

BenefitDescription
Complete Audit TrailEvery change is recorded
Time TravelReconstruct state at any point in time
Debug ProductionReplay events to understand issues
Event-Driven IntegrationNatural fit for event-driven architecture
Flexible ProjectionsBuild any view from events
No Data LossEvents are append-only

Challenges of Event Sourcing

ChallengeMitigation
Learning CurveTraining, start with simple aggregates
Event Schema EvolutionUpcasting, versioning
Query PerformanceSnapshots, CQRS with read models
Storage GrowthArchiving, compaction strategies
Eventual ConsistencyUI patterns, proper user expectations
ComplexityOnly use where benefits outweigh costs

When to Use Event Sourcing

Good Fit:

  • Audit requirements (finance, healthcare, legal)
  • Complex domain with business rules
  • Need for temporal queries ("what was the state on X date?")
  • Event-driven microservices
  • Undo/redo functionality needed

Avoid When:

  • Simple CRUD operations
  • No audit requirements
  • Strong consistency required everywhere
  • Team unfamiliar with the pattern
  • Tight deadlines without time to learn

CQRS & Event Sourcing Interview Questions

Q: What is the difference between CQRS and Event Sourcing?

A: CQRS separates read and write models - you can use it without Event Sourcing (just different databases/models for reads and writes). Event Sourcing stores state as events - you can use it without CQRS. They complement each other well but are independent patterns.

Q: How do you handle eventual consistency in CQRS?

A: Several strategies:

  1. Optimistic UI: Show expected state immediately
  2. Polling/WebSocket: Client checks for updates
  3. Correlation IDs: Track command to query completion
  4. User expectations: Design UI to communicate async nature

Q: How do you deal with event schema changes?

A: Event versioning strategies:

  1. Upcasting: Transform old events to new format when reading
  2. Weak schema: Use flexible schemas (JSON) with defaults
  3. Copy-transform: Create new event stream with migrated events
  4. Multiple handlers: Handle both old and new event versions

Q: What happens if a projection fails?

A:

  1. Retry: Most failures are transient
  2. Dead letter queue: Store failed events for investigation
  3. Rebuild: Projections can be rebuilt from events
  4. Idempotent handlers: Process same event multiple times safely

Q: How do you ensure exactly-once processing?

A: True exactly-once is impossible, but you can achieve effective exactly-once:

  1. Idempotent consumers: Same event produces same result
  2. Deduplication: Track processed event IDs
  3. Transactional outbox: Atomically save state and publish events

Q: When would you NOT use Event Sourcing?

A: Avoid when:

  • Simple CRUD with no audit needs
  • No temporal query requirements
  • Team lacks experience and timeline is tight
  • Strong consistency is critical everywhere
  • Storage constraints for high-volume events

Q: How do snapshots work and when should you use them?

A: Snapshots store aggregate state at a point in time. Instead of replaying all events, load snapshot + events after it. Use when:

  • Aggregates have many events (100+)
  • Load time becomes noticeable
  • Frequency depends on access patterns and event volume

Q: How do you handle commands that need data from multiple aggregates?

A: Options:

  1. Domain service: Coordinate between aggregates
  2. Saga/Process manager: Orchestrate multi-aggregate operations
  3. Read model: Use eventually consistent read model for validation
  4. Aggregate redesign: Reconsider aggregate boundaries

Q: Can you rebuild projections? How?

A: Yes, one of Event Sourcing's key benefits:

  1. Delete existing projection data
  2. Replay all events from the beginning
  3. Apply each event to rebuild state
  4. Consider checkpointing for large event streams

Q: How do you test Event Sourced systems?

A:

// Given-When-Then style
describe('Order', () => {
  it('should cancel order', () => {
    // Given: existing events
    const events = [
      new OrderCreatedEvent(...),
      new PaymentReceivedEvent(...)
    ];
    
    // When: command executed
    const order = Order.rehydrate(events);
    order.cancel('Customer request');
    
    // Then: new events generated
    const newEvents = order.getUncommittedEvents();
    expect(newEvents).toContainEqual(
      expect.objectContaining({ eventType: 'OrderCancelled' })
    );
  });
});

Other Important Patterns

1. CQRS (Command Query Responsibility Segregation)

Separate read and write models

// Command Side (Write)
interface CommandHandler<T> {
  handle(command: T): Promise<void>;
}

class CreateOrderHandler implements CommandHandler<CreateOrderCommand> {
  async handle(command: CreateOrderCommand) {
    const order = new Order(command);
    await this.repository.save(order);
    await this.eventBus.publish(new OrderCreatedEvent(order));
  }
}

// Query Side (Read)
interface QueryHandler<T, R> {
  handle(query: T): Promise<R>;
}

class GetOrderDetailsHandler implements QueryHandler<GetOrderDetailsQuery, OrderDetailsDTO> {
  async handle(query: GetOrderDetailsQuery) {
    // Read from optimized read model/view
    return await this.readModel.getOrderDetails(query.orderId);
  }
}

2. Event Sourcing

Store state as sequence of events

class OrderAggregate {
  private events: DomainEvent[] = [];
  private state: OrderState;
  
  // Recreate state from events
  static fromEvents(events: DomainEvent[]): OrderAggregate {
    const order = new OrderAggregate();
    events.forEach(event => order.apply(event));
    return order;
  }
  
  placeOrder(items: OrderItem[]) {
    const event = new OrderPlacedEvent(this.id, items);
    this.applyAndRecord(event);
  }
  
  private apply(event: DomainEvent) {
    // Update state based on event
    if (event instanceof OrderPlacedEvent) {
      this.state.status = 'PLACED';
      this.state.items = event.items;
    }
  }
  
  private applyAndRecord(event: DomainEvent) {
    this.apply(event);
    this.events.push(event);
  }
}

3. Strangler Fig Pattern

Gradually replace legacy system

Phase 1:            Phase 2:           Phase 3:
┌─────────┐        ┌─────────┐        ┌─────────┐
│ Legacy  │        │ Legacy  │        │  New    │
│ System  │        │ (Partial)│       │ System  │
└─────────┘        ├─────────┤        └─────────┘
                   │   New   │
                   │ Features│
                   └─────────┘

4. Anti-Corruption Layer (ACL)

Translate between different bounded contexts

// Legacy System Interface
interface LegacyOrderService {
  createOrder(data: any): LegacyOrder;
}

// Anti-Corruption Layer
class OrderACL {
  constructor(private legacyService: LegacyOrderService) {}
  
  createOrder(modernOrder: Order): Order {
    // Translate modern model to legacy format
    const legacyData = this.toLegacyFormat(modernOrder);
    
    // Call legacy system
    const legacyOrder = this.legacyService.createOrder(legacyData);
    
    // Translate back to modern model
    return this.toModernFormat(legacyOrder);
  }
  
  private toLegacyFormat(order: Order): any { }
  private toModernFormat(legacyOrder: LegacyOrder): Order { }
}

5. API Composition

Combine data from multiple services

class OrderDetailsComposer {
  constructor(
    private orderService: OrderService,
    private customerService: CustomerService,
    private inventoryService: InventoryService
  ) {}
  
  async getOrderDetails(orderId: string): Promise<OrderDetailsDTO> {
    // Parallel requests to multiple services
    const [order, customer, inventory] = await Promise.all([
      this.orderService.getOrder(orderId),
      this.customerService.getCustomer(order.customerId),
      this.inventoryService.getInventoryStatus(order.items)
    ]);
    
    // Compose response
    return {
      order,
      customer,
      inventoryStatus: inventory
    };
  }
}

6. Sidecar Pattern

Deploy helper components alongside main application

┌──────────────────┐
│  Main Container  │
├──────────────────┤
│ Sidecar Container│
│  - Logging       │
│  - Monitoring    │
│  - Proxy         │
└──────────────────┘

7. Ambassador Pattern

Proxy for external service communication

class DatabaseAmbassador {
  private circuitBreaker: CircuitBreaker;
  private cache: Cache;
  
  async query(sql: string): Promise<any> {
    // Check cache
    const cached = await this.cache.get(sql);
    if (cached) return cached;
    
    // Circuit breaker protection
    return await this.circuitBreaker.execute(async () => {
      const result = await this.database.query(sql);
      await this.cache.set(sql, result);
      return result;
    });
  }
}

8. Adapter Pattern

Convert interface to match client expectations

// Third-party payment service
class StripePaymentService {
  charge(amount: number, token: string) { }
}

// Our payment interface
interface PaymentGateway {
  processPayment(payment: Payment): Promise<void>;
}

// Adapter
class StripeAdapter implements PaymentGateway {
  constructor(private stripe: StripePaymentService) {}
  
  async processPayment(payment: Payment): Promise<void> {
    await this.stripe.charge(
      payment.amount,
      payment.paymentToken
    );
  }
}

9. Outbox Pattern

Ensure reliable event publishing

// Save entity and event in same transaction
class OrderService {
  async createOrder(orderData: CreateOrderDTO) {
    await this.db.transaction(async (tx) => {
      // Save order
      const order = await tx.orders.save(orderData);
      
      // Save event to outbox table
      await tx.outbox.save({
        eventType: 'OrderCreated',
        aggregateId: order.id,
        payload: order,
        status: 'PENDING'
      });
    });
  }
}

// Separate process polls outbox and publishes events
class OutboxPublisher {
  async publishPendingEvents() {
    const events = await this.outbox.findPending();
    
    for (const event of events) {
      await this.eventBus.publish(event.payload);
      await this.outbox.markAsPublished(event.id);
    }
  }
}

10. Backends for Frontends (BFF)

Create specialized backends for different clients

// Mobile BFF
class MobileBFF {
  async getHomePage(userId: string) {
    // Optimized for mobile: minimal data
    return {
      recentOrders: await this.orderService.getRecent(userId, 5),
      notifications: await this.notificationService.getUnread(userId)
    };
  }
}

// Web BFF
class WebBFF {
  async getHomePage(userId: string) {
    // More comprehensive data for web
    return {
      recentOrders: await this.orderService.getRecent(userId, 20),
      notifications: await this.notificationService.getAll(userId),
      recommendations: await this.recommendationService.get(userId),
      analytics: await this.analyticsService.getUserStats(userId)
    };
  }
}

Common Interview Questions

Authentication & Authorization

Q: What's the difference between OAuth 2.0 and OIDC?

A: OAuth 2.0 is for authorization (access to resources). OIDC adds an identity layer on top, providing authentication (who the user is) via ID tokens.

Q: When would you use SAML vs OIDC?

A: SAML for enterprise/legacy systems, XML-based. OIDC for modern apps, mobile-friendly, JSON/JWT-based. OIDC is lighter and better for SPAs/mobile.

Q: How do you implement SSO across microservices?

A: Use a centralized Identity Provider (IdP) with OIDC. Services validate tokens independently. Consider token introspection or shared JWT validation keys.

Clean Architecture

Q: How do you handle cross-cutting concerns in Clean Architecture?

A: Use the Dependency Inversion Principle. Define interfaces in the inner layers (e.g., logging, authentication interfaces in the application layer) and implement them in the outer layers. Inject implementations through constructors.

Microservices

Q: How do you handle distributed transactions in microservices?

A: Use the Saga pattern with either choreography or orchestration. Each service manages its local transaction and publishes events. Implement compensating transactions for rollback scenarios.

Q: How do you maintain data consistency across services?

A: Use eventual consistency with domain events. Services subscribe to events and update their local data. Use the Outbox pattern to ensure reliable event publishing.

DDD

Q: What's the difference between Entity and Value Object?

A: Entities have identity and lifecycle (tracked by ID). Value Objects have no identity and are defined by their attributes. Value Objects are immutable.

Q: When should you use a Domain Service vs Application Service?

A: Domain Services contain business logic that doesn't naturally fit in an entity or value object. Application Services orchestrate use cases and coordinate between domain objects and infrastructure.

Sagas

Q: Choreography vs Orchestration - when to use each?

A:

  • Choreography: Simple workflows, event-driven architecture, loose coupling
  • Orchestration: Complex workflows, need for explicit workflow state, easier testing and debugging

Q: How do you ensure saga compensations are reliable?

A: Make compensations idempotent, retryable, and persist saga state. Use message queues for reliability and implement monitoring/alerting for failed compensations.


Best Practices Summary

Architecture

  • Start with a modular monolith, extract to microservices when needed
  • Define clear bounded contexts
  • Use domain events for cross-context communication
  • Implement proper abstraction layers

Code Quality

  • Follow SOLID principles
  • Write self-documenting code
  • Maintain high test coverage
  • Refactor continuously

Testing

  • Practice TDD
  • Follow testing pyramid (many unit, some integration, few E2E)
  • Test behavior, not implementation
  • Make tests maintainable

Distributed Systems

  • Design for failure
  • Implement circuit breakers and retries
  • Use eventual consistency where appropriate
  • Monitor and trace across services
  • Handle idempotency

Data Management

  • Database per service in microservices
  • Use saga pattern for distributed transactions
  • Implement proper event sourcing when needed
  • Consider CQRS for complex read/write requirements

Authentication & Authorization

Overview

Understanding authentication (AuthN) and authorization (AuthZ) protocols is crucial for designing secure distributed systems.

AspectAuthentication (AuthN)Authorization (AuthZ)
Question"Who are you?""What can you do?"
PurposeVerify identityGrant permissions
ExamplesLogin, MFAAccess control, roles

Protocol Comparison

ProtocolTypeToken FormatBest ForComplexity
OAuth 2.0AuthorizationAccess Token (opaque/JWT)API access, third-party appsMedium
OIDCAuthentication + AuthorizationJWT (ID Token + Access Token)Modern apps, SSOMedium
SAML 2.0Authentication + AuthorizationXML AssertionsEnterprise SSOHigh
JWTToken FormatJSON Web TokenStateless authLow
Basic AuthAuthenticationBase64 encodedSimple APIs, internalLow
API KeysAuthorizationStringService-to-serviceLow

OAuth 2.0 (Open Authorization)

Overview

OAuth 2.0 is an authorization framework that enables third-party applications to obtain limited access to user resources without exposing credentials.

Key Concepts

  • Resource Owner: User who owns the data
  • Client: Application requesting access
  • Authorization Server: Issues access tokens
  • Resource Server: Hosts protected resources

Grant Types

1. Authorization Code (Most Secure)

Best for: Server-side web applications

┌──────────┐                              ┌───────────────┐
│  User    │                              │ Authorization │
│ (Browser)│                              │    Server     │
└────┬─────┘                              └───────┬───────┘
     │                                            │
     │  1. Click "Login with Google"              │
     │─────────────────────────────────────────>  │
     │                                            │
     │  2. Redirect to authorization page         │
     │  <─────────────────────────────────────────│
     │                                            │
     │  3. User authenticates & consents          │
     │─────────────────────────────────────────>  │
     │                                            │
     │  4. Redirect with authorization code       │
     │  <─────────────────────────────────────────│
     │                                            │
┌────┴─────┐                              ┌───────┴───────┐
│  Client  │  5. Exchange code for tokens │ Authorization │
│  Server  │─────────────────────────────>│    Server     │
│          │  6. Access Token + Refresh   │               │
│          │<─────────────────────────────│               │
└──────────┘                              └───────────────┘
// Step 1: Redirect to authorization
const authUrl = `https://auth.example.com/authorize?
  response_type=code&
  client_id=${CLIENT_ID}&
  redirect_uri=${REDIRECT_URI}&
  scope=read:user&
  state=${generateState()}`;

// Step 4: Handle callback
app.get('/callback', async (req, res) => {
  const { code, state } = req.query;
  
  // Verify state to prevent CSRF
  if (!verifyState(state)) {
    return res.status(403).send('Invalid state');
  }
  
  // Step 5: Exchange code for tokens
  const tokens = await exchangeCodeForTokens(code);
  
  // Store tokens securely
  req.session.accessToken = tokens.access_token;
  req.session.refreshToken = tokens.refresh_token;
});
2. Authorization Code with PKCE

Best for: Mobile apps, SPAs (public clients)

// Generate PKCE values
const codeVerifier = generateRandomString(128);
const codeChallenge = base64UrlEncode(sha256(codeVerifier));

// Authorization request includes challenge
const authUrl = `https://auth.example.com/authorize?
  response_type=code&
  client_id=${CLIENT_ID}&
  redirect_uri=${REDIRECT_URI}&
  code_challenge=${codeChallenge}&
  code_challenge_method=S256`;

// Token exchange includes verifier
const tokenResponse = await fetch('https://auth.example.com/token', {
  method: 'POST',
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    code: authorizationCode,
    redirect_uri: REDIRECT_URI,
    client_id: CLIENT_ID,
    code_verifier: codeVerifier  // Proves we initiated the request
  })
});
3. Client Credentials

Best for: Service-to-service (machine-to-machine)

// No user involved - just service authentication
const tokenResponse = await fetch('https://auth.example.com/token', {
  method: 'POST',
  headers: {
    'Authorization': `Basic ${btoa(`${CLIENT_ID}:${CLIENT_SECRET}`)}`
  },
  body: new URLSearchParams({
    grant_type: 'client_credentials',
    scope: 'read:data write:data'
  })
});

const { access_token } = await tokenResponse.json();
4. Resource Owner Password (Legacy - Avoid)

Only for: Trusted first-party apps, migration scenarios

// User credentials sent directly - NOT recommended
const tokenResponse = await fetch('https://auth.example.com/token', {
  method: 'POST',
  body: new URLSearchParams({
    grant_type: 'password',
    username: 'user@example.com',
    password: 'userpassword',
    client_id: CLIENT_ID,
    scope: 'read:user'
  })
});
5. Device Code

Best for: Input-constrained devices (Smart TV, CLI tools)

// Step 1: Request device code
const deviceCodeResponse = await fetch('https://auth.example.com/device/code', {
  method: 'POST',
  body: new URLSearchParams({
    client_id: CLIENT_ID,
    scope: 'read:user'
  })
});

const { device_code, user_code, verification_uri } = await deviceCodeResponse.json();

console.log(`Go to ${verification_uri} and enter code: ${user_code}`);

// Step 2: Poll for token
const pollForToken = async () => {
  while (true) {
    const response = await fetch('https://auth.example.com/token', {
      method: 'POST',
      body: new URLSearchParams({
        grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
        device_code: device_code,
        client_id: CLIENT_ID
      })
    });
    
    const data = await response.json();
    
    if (data.access_token) return data;
    if (data.error === 'authorization_pending') {
      await sleep(5000);
      continue;
    }
    throw new Error(data.error);
  }
};

Token Types

TokenPurposeLifetimeStorage
Access TokenAPI authorizationShort (15min-1hr)Memory
Refresh TokenGet new access tokensLong (days-months)Secure storage

OpenID Connect (OIDC)

Overview

OIDC is an identity layer built on top of OAuth 2.0. While OAuth 2.0 handles authorization, OIDC adds authentication by providing user identity information.

┌─────────────────────────────────────────────────┐
│                    OIDC                          │
│  ┌─────────────────────────────────────────┐    │
│  │              OAuth 2.0                   │    │
│  │  (Authorization - Access Tokens)         │    │
│  └─────────────────────────────────────────┘    │
│  + ID Token (JWT with user identity)            │
│  + UserInfo Endpoint                            │
│  + Standard Claims (name, email, etc.)          │
└─────────────────────────────────────────────────┘

Key Additions Over OAuth 2.0

FeatureOAuth 2.0OIDC
ID Token✅ JWT with user info
UserInfo Endpoint/userinfo
Standard ScopesCustomopenid, profile, email
DiscoveryManual/.well-known/openid-configuration
User IdentityNot definedStandardized claims

ID Token (JWT)

// ID Token Structure
{
  // Header
  "alg": "RS256",
  "typ": "JWT",
  "kid": "key-id-123"
}
.
{
  // Payload (Claims)
  "iss": "https://auth.example.com",          // Issuer
  "sub": "user-123",                          // Subject (unique user ID)
  "aud": "my-client-id",                      // Audience
  "exp": 1735689600,                          // Expiration
  "iat": 1735686000,                          // Issued at
  "nonce": "random-nonce",                    // Prevents replay attacks
  
  // Standard Claims
  "name": "John Doe",
  "email": "john@example.com",
  "email_verified": true,
  "picture": "https://example.com/photo.jpg"
}
.
[signature]

OIDC Flow

// Step 1: Authorization request (note: scope includes 'openid')
const authUrl = `https://auth.example.com/authorize?
  response_type=code&
  client_id=${CLIENT_ID}&
  redirect_uri=${REDIRECT_URI}&
  scope=openid profile email&
  state=${state}&
  nonce=${nonce}`;

// Step 2: Exchange code for tokens (returns ID token + access token)
const tokens = await exchangeCode(code);

// Step 3: Validate ID token
const idToken = tokens.id_token;
const decoded = await validateIdToken(idToken, {
  issuer: 'https://auth.example.com',
  audience: CLIENT_ID,
  nonce: storedNonce
});

// Step 4: Get user info (optional - for additional claims)
const userInfo = await fetch('https://auth.example.com/userinfo', {
  headers: { 'Authorization': `Bearer ${tokens.access_token}` }
});

Standard Scopes & Claims

ScopeClaims Returned
openidsub (required for OIDC)
profilename, family_name, given_name, picture, gender, birthdate, locale
emailemail, email_verified
addressaddress
phonephone_number, phone_number_verified

SAML 2.0 (Security Assertion Markup Language)

Overview

SAML is an XML-based standard for exchanging authentication and authorization data between an Identity Provider (IdP) and a Service Provider (SP).

Key Concepts

  • Identity Provider (IdP): Authenticates users (e.g., Okta, ADFS)
  • Service Provider (SP): Application relying on IdP
  • Assertion: XML document containing authentication info
  • Binding: How SAML messages are transported (HTTP POST, Redirect)

SAML Flow (SP-Initiated)

┌──────────┐         ┌──────────┐         ┌──────────┐
│   User   │         │    SP    │         │   IdP    │
│ (Browser)│         │(Your App)│         │ (Okta)   │
└────┬─────┘         └────┬─────┘         └────┬─────┘
     │                    │                    │
     │ 1. Access app      │                    │
     │───────────────────>│                    │
     │                    │                    │
     │ 2. Redirect with   │                    │
     │    SAML Request    │                    │
     │<───────────────────│                    │
     │                    │                    │
     │ 3. Forward SAML Request                 │
     │────────────────────────────────────────>│
     │                    │                    │
     │ 4. User authenticates                   │
     │<───────────────────────────────────────>│
     │                    │                    │
     │ 5. POST SAML Response (Assertion)       │
     │<────────────────────────────────────────│
     │                    │                    │
     │ 6. Forward to SP   │                    │
     │───────────────────>│                    │
     │                    │                    │
     │ 7. Validate & Create Session            │
     │<───────────────────│                    │
     │                    │                    │

SAML Assertion Structure

<saml:Assertion>
  <saml:Issuer>https://idp.example.com</saml:Issuer>
  
  <saml:Subject>
    <saml:NameID>user@example.com</saml:NameID>
  </saml:Subject>
  
  <saml:Conditions NotBefore="..." NotOnOrAfter="...">
    <saml:AudienceRestriction>
      <saml:Audience>https://sp.example.com</saml:Audience>
    </saml:AudienceRestriction>
  </saml:Conditions>
  
  <saml:AuthnStatement AuthnInstant="...">
    <saml:AuthnContext>
      <saml:AuthnContextClassRef>
        urn:oasis:names:tc:SAML:2.0:ac:classes:Password
      </saml:AuthnContextClassRef>
    </saml:AuthnContext>
  </saml:AuthnStatement>
  
  <saml:AttributeStatement>
    <saml:Attribute Name="email">
      <saml:AttributeValue>user@example.com</saml:AttributeValue>
    </saml:Attribute>
    <saml:Attribute Name="role">
      <saml:AttributeValue>admin</saml:AttributeValue>
    </saml:Attribute>
  </saml:AttributeStatement>
</saml:Assertion>

Single Sign-On (SSO)

Overview

SSO allows users to authenticate once and access multiple applications without re-entering credentials.

SSO Architectures

1. Centralized IdP (Most Common)
                    ┌─────────────────┐
                    │ Identity        │
                    │ Provider (IdP)  │
                    │ (Okta/Auth0)    │
                    └────────┬────────┘
                             │
         ┌───────────────────┼───────────────────┐
         │                   │                   │
    ┌────▼────┐         ┌────▼────┐         ┌────▼────┐
    │  App A  │         │  App B  │         │  App C  │
    │   (SP)  │         │   (SP)  │         │   (SP)  │
    └─────────┘         └─────────┘         └─────────┘
2. Token-Based SSO (JWT)
// Central Auth Service issues JWT
class AuthService {
  login(credentials: Credentials): Promise<{ token: string }> {
    const user = await this.validateCredentials(credentials);
    
    const token = jwt.sign({
      sub: user.id,
      email: user.email,
      roles: user.roles,
      apps: ['app-a', 'app-b', 'app-c']  // Authorized apps
    }, SECRET_KEY, { expiresIn: '8h' });
    
    return { token };
  }
}

// Each app validates the same JWT
class AppA {
  validateToken(token: string) {
    const decoded = jwt.verify(token, SECRET_KEY);
    if (!decoded.apps.includes('app-a')) {
      throw new Error('Not authorized for this app');
    }
    return decoded;
  }
}
3. Session-Based SSO (Cookie)
// Shared session across subdomains
// Auth: auth.example.com
// App A: app-a.example.com
// App B: app-b.example.com

// Set cookie on parent domain
res.cookie('session', sessionId, {
  domain: '.example.com',  // Shared across subdomains
  httpOnly: true,
  secure: true,
  sameSite: 'lax'
});

SSO Implementation Patterns

Pattern 1: Redirect-Based SSO
// App checks authentication
app.use(async (req, res, next) => {
  const session = await getSession(req);
  
  if (!session) {
    // Redirect to central login
    const returnUrl = encodeURIComponent(req.originalUrl);
    return res.redirect(`https://sso.example.com/login?return=${returnUrl}`);
  }
  
  req.user = session.user;
  next();
});
Pattern 2: Token Exchange SSO
// App A has user session, user wants to access App B
class SSOService {
  async generateCrossAppToken(userId: string, targetApp: string): Promise<string> {
    // Short-lived token for app-to-app navigation
    return jwt.sign({
      sub: userId,
      target: targetApp,
      type: 'sso-transfer'
    }, SECRET, { expiresIn: '30s' });
  }
}

// User clicks link to App B
const ssoToken = await ssoService.generateCrossAppToken(user.id, 'app-b');
redirect(`https://app-b.example.com/sso/callback?token=${ssoToken}`);

JWT (JSON Web Token)

Structure

Header.Payload.Signature

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

JWT vs Opaque Tokens

AspectJWTOpaque Token
Self-contained✅ Contains claims❌ Reference only
ValidationLocal (verify signature)Remote (introspection)
RevocationDifficult (until expiry)Easy (delete from store)
SizeLargerSmall
PerformanceBetter (no network call)Requires lookup

JWT Best Practices

// 1. Use appropriate algorithm
const token = jwt.sign(payload, privateKey, { 
  algorithm: 'RS256',  // Asymmetric for distributed systems
  expiresIn: '15m'     // Short expiration
});

// 2. Validate all claims
const decoded = jwt.verify(token, publicKey, {
  algorithms: ['RS256'],  // Prevent algorithm confusion
  issuer: 'https://auth.example.com',
  audience: 'my-api'
});

// 3. Don't store sensitive data
// BAD
{ "password": "secret123" }

// GOOD
{ "sub": "user-123", "roles": ["admin"] }

// 4. Use refresh token rotation
class TokenService {
  async refreshTokens(refreshToken: string) {
    const stored = await this.tokenStore.get(refreshToken);
    
    // Invalidate old refresh token
    await this.tokenStore.delete(refreshToken);
    
    // Issue new pair
    return {
      accessToken: this.generateAccessToken(stored.userId),
      refreshToken: this.generateRefreshToken(stored.userId)
    };
  }
}

Protocol Comparison Summary

When to Use What

ScenarioRecommended Protocol
Third-party API accessOAuth 2.0
User login for web/mobile appsOIDC
Enterprise SSOSAML 2.0 or OIDC
Service-to-serviceOAuth 2.0 Client Credentials
Simple internal APIsAPI Keys or JWT
Social loginOIDC (via providers)
Legacy enterprise systemsSAML 2.0

Feature Comparison

FeatureOAuth 2.0OIDCSAML 2.0
Primary PurposeAuthorizationAuthenticationAuthentication
Token FormatOpaque/JWTJWTXML
TransportJSON/HTTPJSON/HTTPXML/HTTP
Mobile Support✅ Excellent✅ Excellent⚠️ Limited
SPA Support✅ (with PKCE)✅ (with PKCE)
Enterprise Ready
ComplexityMediumMediumHigh
DiscoveryManualStandardizedMetadata XML

Security Comparison

AspectOAuth 2.0OIDCSAML
SignatureOptionalRequired (ID Token)Required
EncryptionOptionalOptionalOptional
Replay ProtectionState paramNonceNotOnOrAfter
CSRF ProtectionState paramState + NonceRelayState

Microservices Authentication Patterns

1. API Gateway Authentication

┌─────────┐     ┌─────────────┐     ┌──────────────┐
│ Client  │────>│ API Gateway │────>│ Microservice │
└─────────┘     │ (Validates  │     └──────────────┘
                │  Token)     │
                └─────────────┘
// API Gateway validates token once
class APIGateway {
  async handleRequest(req: Request) {
    const token = req.headers.authorization?.split(' ')[1];
    
    // Validate token
    const user = await this.authService.validateToken(token);
    
    // Forward with user context
    return this.forwardToService(req, {
      'X-User-Id': user.id,
      'X-User-Roles': user.roles.join(',')
    });
  }
}

2. Service-to-Service Authentication

// Option A: Shared JWT validation
class ServiceA {
  async callServiceB(data: any) {
    // Use service account token
    const serviceToken = await this.getServiceToken();
    
    return fetch('https://service-b/api', {
      headers: {
        'Authorization': `Bearer ${serviceToken}`
      },
      body: JSON.stringify(data)
    });
  }
}

// Option B: mTLS (Mutual TLS)
const httpsAgent = new https.Agent({
  cert: fs.readFileSync('client-cert.pem'),
  key: fs.readFileSync('client-key.pem'),
  ca: fs.readFileSync('ca-cert.pem')
});

3. Token Propagation

// Propagate user context through service chain
class OrderService {
  async createOrder(req: Request, orderData: OrderDTO) {
    const userToken = req.headers.authorization;
    
    // Pass user's token to downstream services
    const inventory = await this.inventoryService.reserve(
      orderData.items,
      { headers: { 'Authorization': userToken } }
    );
    
    const payment = await this.paymentService.charge(
      orderData.total,
      { headers: { 'Authorization': userToken } }
    );
  }
}

Common Security Vulnerabilities

1. Token Storage

// ❌ BAD: localStorage (XSS vulnerable)
localStorage.setItem('token', accessToken);

// ✅ GOOD: httpOnly cookie
res.cookie('access_token', accessToken, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict',
  maxAge: 15 * 60 * 1000
});

// ✅ GOOD: In-memory for SPAs (with refresh rotation)
class TokenManager {
  private accessToken: string | null = null;
  
  setToken(token: string) {
    this.accessToken = token;
  }
}

2. CSRF Protection

// Use state parameter in OAuth
const state = crypto.randomBytes(32).toString('hex');
session.oauthState = state;

const authUrl = `${AUTH_URL}?state=${state}&...`;

// Verify on callback
if (req.query.state !== session.oauthState) {
  throw new Error('CSRF detected');
}

3. Token Leakage Prevention

// Use PKCE for public clients
// Don't expose tokens in URLs (use POST)
// Implement token binding
// Use short-lived access tokens

Duong Ngo

Duong Ngo

Full-Stack AI Developer with 12+ years of experience

Comments