The DevStride backend is built on Domain-Driven Design (DDD) principles with CQRS (Command Query Responsibility Segregation) and event-driven architecture.
| Technology | Purpose |
|---|---|
| Node.js 20 | Runtime |
| TypeScript | Type-safe development (strict mode) |
| Hono | Lightweight web framework for API handlers |
| SST (Serverless Stack) | Infrastructure-as-code |
| DynamoDB | NoSQL database (via dynamodb-onetable) |
| PostgreSQL | Relational database (via Drizzle ORM) |
| BottleJS | Dependency injection |
| @badrap/result | Result pattern for error handling |
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
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/
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));
}
}
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;
}
}
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);
}
}
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,
) {}
}
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);
}
}
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 retrieve data without side effects:
// get-work-item.query.ts
export class GetWorkItemQuery {
constructor(
public readonly organizationId: string,
public readonly itemNumber: string,
) {}
}
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);
Events emitted when state changes:
export class WorkItemCreatedEvent extends DomainEvent {
constructor(
public readonly workItem: WorkItem,
) {
super();
}
}
Events for cross-service communication:
export class WorkItemCreatedIntegrationEvent extends IntegrationEvent {
constructor(
public readonly organizationId: string,
public readonly itemNumber: string,
) {
super();
}
}
React to events:
@injectable()
export class SendNotificationOnItemCreated implements IEventHandler<WorkItemCreatedEvent> {
async handle(event: WorkItemCreatedEvent): Promise<void> {
// Send notification
}
}
// routes.ts
export const itemRoutes = new Hono()
.post('/items', createWorkItemHandler)
.get('/items/:itemNumber', getWorkItemHandler)
.patch('/items/:itemNumber', updateWorkItemHandler);
// 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);
};
// 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>;
}
// 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,
});
}
}
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;
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(),
});
# Generate migration
cd backend && pnpm generate-sql
# Run migrations
./ds migrations run
backend/tests/
├── suits/ # Test suites by module
│ ├── item/
│ │ └── create-item.spec.ts
│ └── board/
├── utils/
│ ├── test-context.ts # Test utilities
│ └── test-context-registry.ts
└── mocks/
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');
});
});
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
any type - Use proper types or unknownI - e.g., IWorkItemRepository