Tiny Store is a modular monolith implementing Domain-Driven Design with event-driven communication.
- Single deployment unit
- Clear module boundaries
- Modules communicate via events only
- Shared infrastructure (database, event bus)
- Rich domain models (business logic in entities)
- Value objects for concepts
- Aggregate roots manage consistency
- Repository pattern for persistence
- Modules publish domain events
- Modules subscribe to events from other modules
- Asynchronous event processing
- Event store for audit trail
- Features organized by capability, not layer
- Each feature is self-contained
- Thin API handlers, fat domain
tiny-store/
├── apps/api/ # HTTP layer (Next.js)
│ └── src/app/
│ ├── api/ # Route handlers
│ └── lib/ # Database, event bus
│
├── libs/modules/ # Bounded contexts
│ ├── orders/
│ │ └── src/
│ │ ├── domain/ # Entities, value objects, events
│ │ ├── features/ # Use cases (vertical slices)
│ │ └── listeners/ # Event handlers
│ ├── inventory/
│ ├── payments/
│ └── shipments/
│
└── libs/shared/
├── domain/ # Base classes, shared value objects
├── infrastructure/ # Event bus, database, event store
└── testing/ # Test utilities
✅ Public API from other modules:
- Feature handlers (PlaceOrderHandler, GetProductHandler)
- Event listeners
- DTOs
✅ Shared libraries:
- Shared domain (Money, Address, Result)
- Shared infrastructure (EventBus, BaseRepository)
✅ Events:
- Subscribe to events from any module
- Publish their own events
❌ Private internals from other modules:
- Domain entities (Order, Product, Payment)
- Repositories (OrderRepository, ProductRepository)
- Value objects (OrderItem, CustomerId, Sku)
- Internal services
- TypeORM entities
Enforcement: Module boundary tests verify these rules.
1. POST /api/orders
↓
2. PlaceOrderHandler
↓
3. OrderAggregate.create()
↓
4. Publish: OrderPlaced
↓
5. OrderPlacedListener (Inventory)
↓
6. ReserveStockService
↓
7. ProductAggregate.reserveStock()
↓
8. Publish: InventoryReserved
↓
9. InventoryReservedListener (Orders)
↓
10. OrderAggregate.confirm()
↓
11. Publish: OrderConfirmed
↓
12. OrderConfirmedListener (Payments)
↓
13. ProcessPaymentService
↓
14. PaymentAggregate.process()
↓
15. Publish: PaymentProcessed
↓
16. PaymentProcessedListener (Orders)
↓
17. OrderAggregate.markAsPaid()
↓
18. Publish: OrderPaid
↓
19. OrderPaidListener (Shipments)
↓
20. CreateShipmentService
↓
21. ShipmentAggregate.create()
↓
22. Publish: ShipmentCreated
States: PENDING → CONFIRMED → PAID → SHIPPED
Alternative: PENDING → REJECTED
Business Rules:
- Cannot cancel after shipment
- Must confirm before payment
- Total calculated from items
Events:
- OrderPlaced
- OrderConfirmed
- OrderRejected
- OrderPaid
- OrderPaymentFailed
- OrderShipped
- OrderCancelled
Business Rules:
- Cannot reserve more than available
- Stock = available + reserved
- Inactive products cannot be reserved
Events:
- InventoryReserved
- InventoryReservationFailed
- InventoryReleased
States: PENDING → PROCESSING → SUCCEEDED
Alternative: PENDING → PROCESSING → FAILED
Business Rules:
- Max 3 retries on failure
- Processing fee calculated based on amount
Events:
- PaymentProcessed
- PaymentFailed
States: PENDING → DISPATCHED → DELIVERED
Events:
- ShipmentCreated
- ShipmentDispatched
- ShipmentDelivered
HTTP Request
↓
Handler (thin)
↓
Service (orchestration)
↓
Aggregate (business logic)
↓
Repository.save()
↓
EventBus.publish()
HTTP Request
↓
Handler
↓
Repository.find()
↓
DTO mapping
↓
HTTP Response
EventBus.publish()
↓
Listener (async)
↓
Handler
↓
Service
↓
Aggregate
↓
Repository.save()
↓
EventBus.publish() (next event)
- Runtime: Node.js 18+
- Language: TypeScript (strict mode)
- Framework: Next.js 15 (App Router)
- Monorepo: Nx
- Database: SQLite via TypeORM
- Event Bus: In-memory pub/sub
- Testing: Jest (310+ tests)
Abstracts data access:
class OrderRepository extends BaseRepository<OrderEntity> {
async findById(id: string): Promise<Order | null> {
const entity = await this.repository.findOne({ where: { id } });
return entity ? Order.reconstitute(entity) : null;
}
}Functional error handling:
const result = Money.create(100, 'USD');
if (result.isSuccess()) {
const money = result.getValue();
} else {
const error = result.getError();
}Decoupled communication:
class Order extends AggregateRoot {
confirm() {
this.status = OrderStatus.CONFIRMED;
this.addDomainEvent(createOrderConfirmedEvent(this.id));
}
}Immutable concepts:
class Money extends ValueObject<{ amount: number; currency: string }> {
add(other: Money): Money {
if (!this.hasSameCurrency(other)) {
throw new Error('Currency mismatch');
}
return Money.create(this.amount + other.amount, this.currency);
}
}- Modular monolith (simple deployment, clear boundaries)
- In-memory event bus (no external dependencies)
- SQLite (zero configuration)
- Async event processing (eventual consistency)
- Microservices (too complex for this scope)
- External message broker (adds infrastructure)
- PostgreSQL (more setup required)
- Synchronous event processing (tight coupling)
Current (Single Instance):
- In-memory event bus
- Single database connection
- No caching
Future (Multiple Instances):
- External message broker (RabbitMQ/Kafka)
- Redis for caching
- PostgreSQL for production
- Distributed tracing
- Circuit breakers
- Unit Tests: Domain logic (150 tests)
- Boundary Tests: Module isolation (20 tests)
- Integration Tests: Event flows (35 tests)
- API Tests: HTTP endpoints (80+ tests)
- Performance Tests: Load & concurrency (25 tests)
See TESTING.md for details.