Setup and context — Why Clean Architecture and DDD Matter
As projects grow in complexity, code becomes increasingly difficult to understand and modify. A codebase that "just works" inevitably accumulates technical debt, making new feature development and bug fixes progressively slower — a reality most development teams face.
Clean Architecture and Domain-Driven Design (DDD) provide powerful solutions to this challenge. However, understanding the principles isn't enough. Implementation often reveals tangled dependencies that pull code away from the original design.
This is where Antigravity's AI agents excel. They can validate architecture, propose refactorings, and even auto-fix violations — helping you maintain production-grade architecture as your project scales.
Clean Architecture Fundamentals
The Four-Layer Structure
Clean Architecture consists of concentric layers, each with a specific responsibility:
1. Entity Layer (Domain Layer)
- Houses core business logic and domain models
- Independent of frameworks, databases, and UI
- Changes least frequently
- Contains pure business rules
2. Use Case Layer (Application Layer)
- Implements application-specific business logic
- Orchestrates entities and external systems
- Defines "how to achieve business goals"
- Coordinates workflows across domain entities
3. Interface Adapter Layer
- Web frameworks (Express, Next.js, etc.)
- Repository implementations
- External API clients
- Acts as a "translator" between inner and outer layers
4. Frameworks and Tools Layer
- Web framework internals
- Database drivers
- Caching, messaging infrastructure
- Third-party libraries
The Dependency Rule
Clean Architecture's most critical principle: all dependencies point inward (toward the center).
Outer layers can depend on inner layers, but inner layers must never depend on outer layers.
// ✓ Allowed dependency directions
// Use Case Layer → Entity Layer
// Adapter Layer → Use Case Layer → Entity Layer
// ✗ Forbidden dependency directions
// Entity Layer → Use Case Layer
// Entity Layer → Adapter LayerThis achieves:
- Resilience to external change: Framework changes don't affect business logic
- Testability: Business logic can be tested without frameworks
- Reusability: Same business logic works across multiple interfaces (Web, CLI, API)
Connection to SOLID Principles
Clean Architecture is a concrete realization of SOLID principles:
- S (Single Responsibility): Each class has one reason to change
- O (Open/Closed): Open for extension, closed for modification
- L (Liskov Substitution): Subtypes can replace their base types
- I (Interface Segregation): Clients depend only on needed methods
- D (Dependency Inversion): Depend on abstractions, not concretions
Project Structure Design
Optimal Directory Organization
src/
├── domain/ # Entity Layer
│ ├── entities/
│ │ ├── User.ts
│ │ ├── Order.ts
│ │ └── Product.ts
│ ├── value-objects/
│ │ ├── Email.ts
│ │ ├── Money.ts
│ │ └── UserId.ts
│ ├── repositories/
│ │ ├── IUserRepository.ts
│ │ ├── IOrderRepository.ts
│ │ └── IProductRepository.ts
│ └── services/
│ ├── PricingDomainService.ts
│ └── InventoryDomainService.ts
│
├── application/ # Use Case Layer
│ ├── use-cases/
│ │ ├── CreateOrderUseCase.ts
│ │ ├── UpdateUserProfileUseCase.ts
│ │ └── GetProductDetailsUseCase.ts
│ ├── dto/
│ │ ├── CreateOrderRequest.ts
│ │ ├── CreateOrderResponse.ts
│ │ └── GetProductResponse.ts
│ ├── services/
│ │ ├── OrderApplicationService.ts
│ │ └── UserApplicationService.ts
│ └── ports/
│ ├── IEmailService.ts
│ ├── IPaymentGateway.ts
│ └── INotificationService.ts
│
├── infrastructure/ # Adapter & Tools Layer
│ ├── repositories/
│ │ ├── UserRepository.ts
│ │ ├── OrderRepository.ts
│ │ └── ProductRepository.ts
│ ├── external-services/
│ │ ├── StripePaymentGateway.ts
│ │ ├── SendgridEmailService.ts
│ │ └── TwilioNotificationService.ts
│ ├── persistence/
│ │ ├── prisma/
│ │ └── schema.prisma
│ └── config/
│ ├── database.ts
│ └── dependencies.ts
│
├── presentation/ # Controllers & Handlers Layer
│ ├── http/
│ │ ├── controllers/
│ │ │ ├── OrderController.ts
│ │ │ └── UserController.ts
│ │ └── middleware/
│ │ ├── auth.ts
│ │ └── errorHandler.ts
│ └── cli/
│ └── commands/
│ └── MigrateCommand.ts
│
└── shared/ # Shared Utilities
├── errors/
│ ├── DomainError.ts
│ └── ApplicationError.ts
├── utils/
│ ├── Logger.ts
│ └── Validator.ts
└── types/
└── index.ts
Module Separation Strategy
For large projects, organize around business domains (Bounded Contexts):
src/
├── modules/
│ ├── user/
│ │ ├── domain/
│ │ ├── application/
│ │ ├── infrastructure/
│ │ └── presentation/
│ ├── order/
│ │ ├── domain/
│ │ ├── application/
│ │ ├── infrastructure/
│ │ └── presentation/
│ └── payment/
│ ├── domain/
│ ├── application/
│ ├── infrastructure/
│ └── presentation/
Each module implements the four-layer architecture independently. Inter-module communication happens only through public interfaces.
Domain Layer Implementation
Entity Design
Entities are domain objects with unique identifiers. While attributes change over time, the identity remains constant.
// User entity
import { UserId } from './value-objects/UserId';
import { Email } from './value-objects/Email';
export class User {
private userId: UserId;
private email: Email;
private name: string;
private age: number;
private createdAt: Date;
private updatedAt: Date;
constructor(
userId: UserId,
email: Email,
name: string,
age: number,
createdAt: Date = new Date(),
updatedAt: Date = new Date()
) {
this.validate(name, age);
this.userId = userId;
this.email = email;
this.name = name;
this.age = age;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}
private validate(name: string, age: number): void {
if (!name || name.trim().length === 0) {
throw new Error('User name must not be empty');
}
if (age < 0 || age > 150) {
throw new Error('User age must be between 0 and 150');
}
}
// Business logic: age validation
isAdult(): boolean {
return this.age >= 18;
}
// Business logic: update email
changeEmail(newEmail: Email): void {
this.email = newEmail;
this.updatedAt = new Date();
}
// Business logic: update profile
updateProfile(name: string, age: number): void {
this.validate(name, age);
this.name = name;
this.age = age;
this.updatedAt = new Date();
}
// Getters
getId(): UserId {
return this.userId;
}
getEmail(): Email {
return this.email;
}
getName(): string {
return this.name;
}
getAge(): number {
return this.age;
}
getCreatedAt(): Date {
return this.createdAt;
}
getUpdatedAt(): Date {
return this.updatedAt;
}
}Value Objects
Value objects lack identity and are distinguished by their values. Examples include email addresses, money amounts, and physical addresses.
// Email value object
export class Email {
private readonly value: string;
constructor(value: string) {
if (!this.isValid(value)) {
throw new Error(`Invalid email format: ${value}`);
}
this.value = value;
}
private isValid(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
getValue(): string {
return this.value;
}
equals(other: Email): boolean {
return this.value === other.value;
}
toString(): string {
return this.value;
}
}
// Money value object
export class Money {
private readonly amount: number;
private readonly currency: string;
constructor(amount: number, currency: string = 'USD') {
if (amount < 0) {
throw new Error('Money amount must not be negative');
}
if (!/^[A-Z]{3}$/.test(currency)) {
throw new Error('Currency must be a valid ISO 4217 code');
}
this.amount = amount;
this.currency = currency;
}
getAmount(): number {
return this.amount;
}
getCurrency(): string {
return this.currency;
}
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error('Cannot add money in different currencies');
}
return new Money(this.amount + other.amount, this.currency);
}
multiply(factor: number): Money {
return new Money(this.amount * factor, this.currency);
}
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency;
}
toString(): string {
return `${this.currency} ${this.amount.toFixed(2)}`;
}
}
// UserId value object
export class UserId {
private readonly value: string;
constructor(value: string) {
if (!value || value.trim().length === 0) {
throw new Error('UserId must not be empty');
}
this.value = value;
}
getValue(): string {
return this.value;
}
equals(other: UserId): boolean {
return this.value === other.value;
}
toString(): string {
return this.value;
}
}Domain Services
Cross-cutting business logic that involves multiple entities belongs in domain services.
// Pricing domain service
export class PricingDomainService {
// Calculate discount based on membership and quantity
calculateDiscount(
user: User,
itemCount: number
): number {
let discountRate = 0;
// Premium members get 10% discount
if (user.isPremiumMember()) {
discountRate = 0.1;
}
// Bulk purchases earn additional 5% discount
if (itemCount >= 10) {
discountRate += 0.05;
}
return discountRate;
}
// Calculate tax
calculateTax(
price: Money,
taxRate: number
): Money {
const taxAmount = price.getAmount() * taxRate;
return new Money(taxAmount, price.getCurrency());
}
// Calculate final price (discount and tax applied)
calculateFinalPrice(
basePrice: Money,
user: User,
itemCount: number,
taxRate: number
): Money {
const discountRate = this.calculateDiscount(user, itemCount);
const discountedPrice = basePrice.multiply(1 - discountRate);
const tax = this.calculateTax(discountedPrice, taxRate);
return discountedPrice.add(tax);
}
}
// Inventory domain service
export class InventoryDomainService {
// Check if stock is sufficient
hasEnoughStock(product: Product, requestedQuantity: number): boolean {
return product.getStock() >= requestedQuantity;
}
// Reserve stock
reserveStock(product: Product, quantity: number): void {
if (!this.hasEnoughStock(product, quantity)) {
throw new Error('Insufficient stock');
}
product.reserveStock(quantity);
}
// Release reserved stock
releaseStock(product: Product, quantity: number): void {
product.releaseStock(quantity);
}
}Repository Interfaces
Repositories define contracts in the domain layer, with implementations in the infrastructure layer.
// Repository interfaces (Domain Layer)
export interface IUserRepository {
save(user: User): Promise<void>;
findById(userId: UserId): Promise<User | null>;
findByEmail(email: Email): Promise<User | null>;
delete(userId: UserId): Promise<void>;
findAll(): Promise<User[]>;
}
export interface IOrderRepository {
save(order: Order): Promise<void>;
findById(orderId: OrderId): Promise<Order | null>;
findByUserId(userId: UserId): Promise<Order[]>;
delete(orderId: OrderId): Promise<void>;
}
export interface IProductRepository {
save(product: Product): Promise<void>;
findById(productId: ProductId): Promise<Product | null>;
findAll(): Promise<Product[]>;
delete(productId: ProductId): Promise<void>;
}Application Layer Implementation
Application Services and Use Cases
Use cases model what users want to achieve. They orchestrate domain logic and coordinate external dependencies.
// DTOs: Data Transfer Objects
export class CreateOrderRequest {
constructor(
readonly userId: string,
readonly productIds: string[],
readonly quantities: number[]
) {}
}
export class CreateOrderResponse {
constructor(
readonly orderId: string,
readonly totalPrice: string,
readonly status: string
) {}
}
// Use Case: Create an order
export class CreateOrderUseCase {
constructor(
private userRepository: IUserRepository,
private productRepository: IProductRepository,
private orderRepository: IOrderRepository,
private pricingService: PricingDomainService,
private inventoryService: InventoryDomainService,
private paymentGateway: IPaymentGateway,
private notificationService: INotificationService
) {}
async execute(request: CreateOrderRequest): Promise<CreateOrderResponse> {
// Fetch user
const user = await this.userRepository.findById(
new UserId(request.userId)
);
if (!user) {
throw new Error('User not found');
}
// Fetch products and verify stock
const products: Product[] = [];
for (const productId of request.productIds) {
const product = await this.productRepository.findById(
new ProductId(productId)
);
if (!product) {
throw new Error(`Product ${productId} not found`);
}
products.push(product);
}
// Reserve stock
for (let i = 0; i < products.length; i++) {
this.inventoryService.reserveStock(
products[i],
request.quantities[i]
);
}
// Create order entity
const order = Order.create(
user,
products,
request.quantities,
this.pricingService
);
// Process payment
const paymentResult = await this.paymentGateway.charge(
user.getEmail(),
order.getTotalPrice()
);
if (!paymentResult.success) {
// Release stock on payment failure
for (let i = 0; i < products.length; i++) {
this.inventoryService.releaseStock(
products[i],
request.quantities[i]
);
}
throw new Error('Payment failed');
}
// Persist order
order.markAsPaid(paymentResult.transactionId);
await this.orderRepository.save(order);
// Send confirmation
await this.notificationService.sendOrderConfirmation(
user.getEmail(),
order
);
return new CreateOrderResponse(
order.getId().getValue(),
order.getTotalPrice().toString(),
order.getStatus()
);
}
}
// Application Service
export class OrderApplicationService {
constructor(
private createOrderUseCase: CreateOrderUseCase,
// Other use cases...
) {}
async createOrder(request: CreateOrderRequest): Promise<CreateOrderResponse> {
return this.createOrderUseCase.execute(request);
}
}Infrastructure Layer
Repository Implementation
Prisma-based implementation of domain repository interfaces:
// Implementing domain interface
export class UserRepository implements IUserRepository {
constructor(private prisma: PrismaClient) {}
async save(user: User): Promise<void> {
await this.prisma.user.upsert({
where: { id: user.getId().getValue() },
update: {
email: user.getEmail().getValue(),
name: user.getName(),
age: user.getAge(),
updatedAt: user.getUpdatedAt(),
},
create: {
id: user.getId().getValue(),
email: user.getEmail().getValue(),
name: user.getName(),
age: user.getAge(),
createdAt: user.getCreatedAt(),
updatedAt: user.getUpdatedAt(),
},
});
}
async findById(userId: UserId): Promise<User | null> {
const record = await this.prisma.user.findUnique({
where: { id: userId.getValue() },
});
if (!record) {
return null;
}
return this.toDomain(record);
}
async findByEmail(email: Email): Promise<User | null> {
const record = await this.prisma.user.findUnique({
where: { email: email.getValue() },
});
if (!record) {
return null;
}
return this.toDomain(record);
}
async findAll(): Promise<User[]> {
const records = await this.prisma.user.findMany();
return records.map((record) => this.toDomain(record));
}
async delete(userId: UserId): Promise<void> {
await this.prisma.user.delete({
where: { id: userId.getValue() },
});
}
// Convert database record to domain entity
private toDomain(record: any): User {
return new User(
new UserId(record.id),
new Email(record.email),
record.name,
record.age,
record.createdAt,
record.updatedAt
);
}
}External Services (Port Implementations)
Payment gateways and other integrations implement port interfaces:
// Port interface (Application Layer)
export interface IPaymentGateway {
charge(email: string, amount: Money): Promise<PaymentResult>;
refund(transactionId: string): Promise<RefundResult>;
}
export interface PaymentResult {
success: boolean;
transactionId?: string;
errorMessage?: string;
}
// Implementation (Infrastructure Layer)
export class StripePaymentGateway implements IPaymentGateway {
constructor(private stripeClient: Stripe) {}
async charge(email: string, amount: Money): Promise<PaymentResult> {
try {
const paymentIntent = await this.stripeClient.paymentIntents.create({
amount: Math.round(amount.getAmount() * 100), // cents
currency: amount.getCurrency().toLowerCase(),
receipt_email: email,
});
if (paymentIntent.status === 'succeeded') {
return {
success: true,
transactionId: paymentIntent.id,
};
} else {
return {
success: false,
errorMessage: `Payment status: ${paymentIntent.status}`,
};
}
} catch (error) {
return {
success: false,
errorMessage: (error as Error).message,
};
}
}
async refund(transactionId: string): Promise<RefundResult> {
try {
const refund = await this.stripeClient.refunds.create({
payment_intent: transactionId,
});
return {
success: refund.status === 'succeeded',
refundId: refund.id,
};
} catch (error) {
return {
success: false,
errorMessage: (error as Error).message,
};
}
}
}Dependency Injection and DI Containers
DI Container Design
Centralize all dependency management:
// DI Container
export class Container {
private instances: Map<string, any> = new Map();
private factories: Map<string, () => any> = new Map();
// Register singleton
registerSingleton<T>(key: string, factory: () => T): void {
this.factories.set(key, factory);
}
// Register instance directly
registerInstance<T>(key: string, instance: T): void {
this.instances.set(key, instance);
}
// Get instance (cached)
get<T>(key: string): T {
if (this.instances.has(key)) {
return this.instances.get(key);
}
const factory = this.factories.get(key);
if (!factory) {
throw new Error(`No factory registered for key: ${key}`);
}
const instance = factory();
this.instances.set(key, instance);
return instance;
}
}
// Setup function
export function setupDependencies(): Container {
const container = new Container();
const prisma = new PrismaClient();
// Infrastructure Layer
container.registerInstance('prisma', prisma);
container.registerInstance(
'stripeClient',
new Stripe(process.env.STRIPE_SECRET_KEY || '')
);
// Repositories
container.registerSingleton('userRepository', () => {
return new UserRepository(container.get('prisma'));
});
container.registerSingleton('productRepository', () => {
return new ProductRepository(container.get('prisma'));
});
container.registerSingleton('orderRepository', () => {
return new OrderRepository(container.get('prisma'));
});
// Domain Services
container.registerSingleton('pricingDomainService', () => {
return new PricingDomainService();
});
container.registerSingleton('inventoryDomainService', () => {
return new InventoryDomainService();
});
// External Services
container.registerSingleton('paymentGateway', () => {
return new StripePaymentGateway(container.get('stripeClient'));
});
container.registerSingleton('emailService', () => {
return new SendgridEmailService(process.env.SENDGRID_API_KEY || '');
});
container.registerSingleton('notificationService', () => {
return new NotificationService(container.get('emailService'));
});
// Use Cases
container.registerSingleton('createOrderUseCase', () => {
return new CreateOrderUseCase(
container.get('userRepository'),
container.get('productRepository'),
container.get('orderRepository'),
container.get('pricingDomainService'),
container.get('inventoryDomainService'),
container.get('paymentGateway'),
container.get('notificationService')
);
});
// Application Services
container.registerSingleton('orderApplicationService', () => {
return new OrderApplicationService(
container.get('createOrderUseCase')
);
});
return container;
}Architecture Analysis with Antigravity Agents
Automated Architecture Violation Detection
Have Antigravity's agents analyze your codebase for Clean Architecture violations:
Instruct Antigravity:
Analyze the project's dependency structure and report:
1. Reverse Dependencies:
- Domain layer depending on Application or Infrastructure layers
- Use Case layer directly depending on Adapter layer
2. Layer Skipping:
- Presentation layer directly depending on Domain layer
- Infrastructure and Domain layers directly communicating
3. Mixed Concerns:
- Business logic mingled with HTTP handlers
- Database schema structure leaking into domain models
4. Port-Adapter Violations:
- External libraries depending on business logic
- Repository interfaces depending on implementations
Antigravity generates a dependency graph and returns results like:
【ARCHITECTURE VIOLATION REPORT】
【Severity: HIGH】
- OrderController (Presentation) → Order (Domain) direct dependency
↳ Cause: new Order() instantiation in controller
↳ Fix: Route through CreateOrderUseCase
- User (Domain) → UserRepository implementation (Infrastructure)
↳ Cause: importing infrastructure package in domain/entities/User.ts
↳ Fix: Import interface only
【Severity: MEDIUM】
- OrderApplicationService → Stripe (external library) direct dependency
↳ Cause: StripePaymentGateway in application layer
↳ Fix: Move to infrastructure layer
Automated Refactoring Suggestions
Antigravity can propose fixes:
Instruct Antigravity:
Fix the violation in src/presentation/controllers/OrderController.ts:
// Current code (violates rule)
export class OrderController {
post(req: Request, res: Response) {
const order = new Order(req.body); // ← direct instantiation (violation)
// ...
}
}
Refactoring points:
1. Inject CreateOrderUseCase via DI container
2. Create order through use case
3. Show refactored code
Refactored code:
export class OrderController {
constructor(private orderApplicationService: OrderApplicationService) {}
async post(req: Request, res: Response) {
try {
const request = new CreateOrderRequest(
req.body.userId,
req.body.productIds,
req.body.quantities
);
const response = await this.orderApplicationService.createOrder(request);
res.json(response);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
}
}Defining Rules with AGENTS.md
Define project-specific architecture rules that Antigravity enforces:
# Architecture Rules for Clean Architecture + DDD
## Layer Dependencies
- domain/* must not depend on application/*, infrastructure/*, presentation/*
- application/* must not depend on infrastructure/* (interfaces only)
- presentation/* must not directly depend on infrastructure/* (via application layer)
## Module Imports
- No imports from domain/ to infrastructure/
- No imports from domain/ to external/
- Business logic (methods) must be in the same file as value objects
## Repository Pattern
- Repository implementations only in infrastructure/repositories/
- Application layer depends only on IRepository interfaces
- Repositories manage entity lifecycles
## Dependency Injection
- All external dependencies via constructor injection
- No global variables or Service Locator pattern
- Concentrate container setup in config/Antigravity references these rules for violation detection and refactoring suggestions.
Conclusion
Implementing Clean Architecture × DDD with Antigravity provides significant benefits:
1. Maintainability
- Layer separation limits change impact
- Domain logic directly reflects business requirements
2. Testability
- Test business logic without databases or external services
- Faster test execution
3. Reusability
- Same business logic works across Web, CLI, API interfaces
4. Scalability
- Supports larger teams and simpler onboarding
- Architecture remains clear as project grows
5. Antigravity Automation
- Auto-detect architecture violations
- Propose refactorings and auto-fix issues
- Maintain design quality continuously
For production applications requiring long-term maintenance and team collaboration, Clean Architecture and DDD represent sound architectural decisions. Antigravity's agent capabilities help you maintain these principles efficiently as you scale.