Architecture

Backend Architecture

Deep dive into DevStride's backend architecture, patterns, and conventions.

Backend Architecture

The DevStride backend is built on Domain-Driven Design (DDD) principles with CQRS (Command Query Responsibility Segregation) and event-driven architecture.

Technology Stack

TechnologyPurpose
Node.js 20Runtime
TypeScriptType-safe development (strict mode)
HonoLightweight web framework for API handlers
SST (Serverless Stack)Infrastructure-as-code
DynamoDBNoSQL database (via dynamodb-onetable)
PostgreSQLRelational database (via Drizzle ORM)
BottleJSDependency injection
@badrap/resultResult pattern for error handling

Project Structure

backend/src/
├── libs/
│   ├── domain/           # Shared domain primitives
│   │   ├── ports/        # Interface definitions
│   │   └── config.ts     # Configuration schema
│   ├── infrastructure/   # Shared infrastructure
│   │   ├── pusher/       # Real-time messaging
│   │   └── ...
│   └── exceptions/       # Exception classes
│
├── modules/              # Domain modules
│   ├── item/
│   ├── board/
│   ├── user/
│   ├── organization/
│   └── ...
│
└── index.ts              # Application entry point

Module Structure

Each domain module follows a consistent structure:

modules/item/
├── domain/
│   ├── entities/              # Domain entities
│   │   └── work-item.entity.ts
│   ├── value-objects/         # Value objects
│   │   └── item-number.vo.ts
│   ├── events/                # Domain events
│   │   └── work-item-created.event.ts
│   └── ports/                 # Repository interfaces
│       └── work-item.repository.port.ts
│
├── application/
│   ├── commands/              # Write operations
│   │   └── create-work-item/
│   │       ├── create-work-item.command.ts
│   │       ├── create-work-item.service.ts
│   │       └── create-work-item.init.ts
│   └── queries/               # Read operations
│       └── get-work-item/
│           ├── get-work-item.query.ts
│           └── get-work-item.service.ts
│
├── infrastructure/
│   └── repositories/          # Repository implementations
│       └── work-item.repository.ts
│
└── interface-adapters/
    ├── hono/                  # HTTP handlers
    │   ├── routes.ts
    │   └── handlers/
    └── dtos/                  # Data transfer objects
        ├── requests/
        └── responses/

Core Patterns

Domain-Driven Design

Entities

Objects with identity that persist over time:

export class WorkItem extends Entity<WorkItemProps> {
  get number(): ItemNumber {
    return this.props.number;
  }

  get title(): string {
    return this.props.title;
  }

  updateTitle(title: string): void {
    this.props.title = title;
    this.addDomainEvent(new WorkItemTitleUpdatedEvent(this));
  }
}

Value Objects

Immutable objects defined by their attributes:

export class ItemNumber extends ValueObject<{ value: string }> {
  static create(value: string): Result<ItemNumber, ValidationError> {
    if (!value || value.length === 0) {
      return Result.err(new ValidationError('Item number required'));
    }
    return Result.ok(new ItemNumber({ value }));
  }

  get value(): string {
    return this.props.value;
  }
}

Aggregate Roots

Entities that control access to a cluster of objects:

export class Board extends AggregateRoot<BoardProps> {
  addItem(item: WorkItem): Result<void, DomainError> {
    // Business logic and invariant enforcement
    this.props.items.push(item);
    this.addDomainEvent(new ItemAddedToBoardEvent(this, item));
    return Result.ok(undefined);
  }
}

CQRS Pattern

Commands

Commands represent intent to change state:

// create-work-item.command.ts
export class CreateWorkItemCommand {
  constructor(
    public readonly organizationId: string,
    public readonly title: string,
    public readonly workTypeId: string,
    public readonly createdBy: string,
  ) {}
}

Command Handlers (Services)

Services execute commands and return results:

// create-work-item.service.ts
@injectable()
export class CreateWorkItemService implements ICommandHandler<CreateWorkItemCommand> {
  constructor(
    private readonly workItemRepository: IWorkItemRepository,
    private readonly eventBus: IEventBus,
  ) {}

  async execute(command: CreateWorkItemCommand): Promise<Result<WorkItem, DomainError>> {
    // Create entity
    const itemResult = WorkItem.create({
      title: command.title,
      // ...
    });

    if (itemResult.isErr) {
      return itemResult;
    }

    // Persist
    await this.workItemRepository.save(itemResult.value);

    // Publish events
    await this.eventBus.publishAll(itemResult.value.domainEvents);

    return Result.ok(itemResult.value);
  }
}

Init Files

Register commands with the command bus:

// create-work-item.init.ts
export function init(container: Container): void {
  container.commandBus.register(
    CreateWorkItemCommand,
    container.resolve(CreateWorkItemService),
  );
}

Queries

Queries retrieve data without side effects:

// get-work-item.query.ts
export class GetWorkItemQuery {
  constructor(
    public readonly organizationId: string,
    public readonly itemNumber: string,
  ) {}
}

Result Pattern

All operations return Result<T, E> instead of throwing:

import { Result } from '@badrap/result';

async function updateItem(id: string, title: string): Promise<Result<WorkItem, DomainError>> {
  const item = await this.repository.findById(id);

  if (!item) {
    return Result.err(new NotFoundError('Item not found'));
  }

  const updateResult = item.updateTitle(title);
  if (updateResult.isErr) {
    return updateResult;
  }

  await this.repository.save(item);
  return Result.ok(item);
}

// Usage
const result = await updateItem('123', 'New Title');

if (result.isErr) {
  // Handle error
  return response.status(400).json({ error: result.error.message });
}

// Use result.value
return response.json(result.value);

Event-Driven Architecture

Domain Events

Events emitted when state changes:

export class WorkItemCreatedEvent extends DomainEvent {
  constructor(
    public readonly workItem: WorkItem,
  ) {
    super();
  }
}

Integration Events

Events for cross-service communication:

export class WorkItemCreatedIntegrationEvent extends IntegrationEvent {
  constructor(
    public readonly organizationId: string,
    public readonly itemNumber: string,
  ) {
    super();
  }
}

Event Handlers

React to events:

@injectable()
export class SendNotificationOnItemCreated implements IEventHandler<WorkItemCreatedEvent> {
  async handle(event: WorkItemCreatedEvent): Promise<void> {
    // Send notification
  }
}

HTTP Layer (Hono)

Route Definition

// routes.ts
export const itemRoutes = new Hono()
  .post('/items', createWorkItemHandler)
  .get('/items/:itemNumber', getWorkItemHandler)
  .patch('/items/:itemNumber', updateWorkItemHandler);

Handler Pattern

// create-work-item.handler.ts
export const createWorkItemHandler = async (c: Context) => {
  // 1. Parse and validate input
  const body = await c.req.json();
  const validated = CreateWorkItemRequestSchema.parse(body);

  // 2. Create command
  const command = new CreateWorkItemCommand(
    c.get('organizationId'),
    validated.title,
    validated.workTypeId,
    c.get('userId'),
  );

  // 3. Execute via command bus
  const result = await c.get('commandBus').execute(command);

  // 4. Handle result
  if (result.isErr) {
    return c.json({ error: result.error.message }, 400);
  }

  // 5. Return response
  return c.json(WorkItemResponseDto.fromEntity(result.value), 201);
};

Repository Pattern

Port (Interface)

// work-item.repository.port.ts
export interface IWorkItemRepository {
  findByNumber(orgId: string, number: string): Promise<WorkItem | null>;
  findById(id: string): Promise<WorkItem | null>;
  save(item: WorkItem): Promise<void>;
  delete(item: WorkItem): Promise<void>;
}

Implementation

// work-item.repository.ts
@injectable()
export class WorkItemRepository implements IWorkItemRepository {
  constructor(private readonly db: Database) {}

  async findByNumber(orgId: string, number: string): Promise<WorkItem | null> {
    const row = await this.db.query.workItems.findFirst({
      where: and(
        eq(workItems.organizationId, orgId),
        eq(workItems.number, number),
      ),
    });

    return row ? WorkItemMapper.toDomain(row) : null;
  }

  async save(item: WorkItem): Promise<void> {
    const data = WorkItemMapper.toPersistence(item);
    await this.db.insert(workItems).values(data).onConflictDoUpdate({
      target: workItems.id,
      set: data,
    });
  }
}

Dependency Injection

Using BottleJS for DI:

// container.ts
const container = new Bottle();

// Register repositories
container.service('workItemRepository', WorkItemRepository, 'database');

// Register services
container.service('createWorkItemService', CreateWorkItemService, 'workItemRepository', 'eventBus');

// Resolve
const service = container.container.createWorkItemService;

Database

PostgreSQL with Drizzle ORM

Schema definition:

// work-item.sql-entity.ts
export const workItems = pgTable('work_items', {
  id: uuid('id').primaryKey(),
  organizationId: uuid('organization_id').notNull(),
  number: varchar('number', { length: 50 }).notNull(),
  title: varchar('title', { length: 500 }).notNull(),
  createdAt: timestamp('created_at').defaultNow(),
  updatedAt: timestamp('updated_at').defaultNow(),
});

Migrations

# Generate migration
cd backend && pnpm generate-sql

# Run migrations
./ds migrations run

Testing

Test Structure

backend/tests/
├── suits/              # Test suites by module
│   ├── item/
│   │   └── create-item.spec.ts
│   └── board/
├── utils/
│   ├── test-context.ts    # Test utilities
│   └── test-context-registry.ts
└── mocks/

Writing Tests

describe('CreateWorkItem', () => {
  let ctx: TestContext;

  beforeEach(async () => {
    ctx = await TestContext.create();
  });

  afterEach(async () => {
    await ctx.cleanup();
  });

  it('should create a work item', async () => {
    const result = await ctx.commandBus.execute(
      new CreateWorkItemCommand(
        ctx.organization.id,
        'Test Item',
        ctx.workType.id,
        ctx.user.id,
      ),
    );

    expect(result.isOk).toBe(true);
    expect(result.value.title).toBe('Test Item');
  });
});

Running Tests

cd backend

# Run all tests
pnpm test

# Run specific test file
pnpm test -- tests/suits/item/create-item.spec.ts

# Run with pattern
pnpm test -- -t "should create"

# Run with coverage
pnpm coverage

Code Style

  • Never use any type - Use proper types or unknown
  • Prefix interfaces with I - e.g., IWorkItemRepository
  • Use async/await - Not raw Promises
  • Use Result pattern - Don't throw for expected errors
  • Single quotes, semicolons required
  • Follow DDD naming conventions