Architecture Layers
Dependencies point inward. Inner layers must not know about outer layers. The layers from outside in: External (Web, CLI, GraphQL) → Infrastructure (Repos, Adapters, ORM) → Application (Use Cases, Services) → Domain (Entities, Value Objects, Services).
Domain Layer
Business rules isolated from technical concerns. Fields should be private by default. Domain objects encode the rules of the domain itself.
For example, rather than writing >= 18 in your application layer to check if a user is an adult, the User entity should have an IsAdult() method. This way, when domain logic changes, you see it in version control as a domain change — not scattered across use cases.
- Entities: Objects with identity and business logic
- Value Objects: Immutable objects without identity
- Domain Services: Stateless operations across multiple domain objects (e.g.
TransferMoney— it wouldn't make sense as a method on a singleMoneyobject) - Repository Interfaces: Data access contracts (implemented by infrastructure)
// Entity with behavior export class User { constructor( public readonly id: UserId, private passwordHash: PasswordHash, ) {} changePassword(newPassword: Password, hasher: PasswordHasher): void { this.passwordHash = hasher.hash(newPassword); } } // Value Object - immutable, validated export class Email { private constructor(private readonly value: string) {} static create(email: string): Email { if (!this.isValid(email)) throw new InvalidEmailError(email); return new Email(email.toLowerCase()); } private static isValid(email: string): boolean { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); } equals(other: Email): boolean { return this.value === other.value; } } // Repository interface - implemented by infrastructure export interface UserRepository { findById(id: UserId): Promise<User | null>; save(user: User): Promise<void>; }
Application Layer
The application layer is arguably the most complex layer — it's where differentiation happens. But if all other layers are done right, you end up working only with interfaces and domain object methods. The code should read like English.
- Use Cases: Single responsibility operations
- DTOs: Data transfer at boundaries
- Ports: Interfaces for external dependencies
export class CreateUserUseCase { constructor( private readonly userRepository: UserRepository, private readonly passwordHasher: PasswordHasher, ) {} async execute(input: CreateUserInput): Promise<CreateUserOutput> { const existing = await this.userRepository.findByEmail( Email.create(input.email), ); if (existing) throw new EmailAlreadyExistsError(); const user = new User( UserId.generate(), Email.create(input.email), this.passwordHasher.hash(input.password), new Date(), ); await this.userRepository.save(user); return user.toDTO(); } }
Infrastructure Layer
Implements interfaces from the domain layer.
- Repository Implementations: Database access
- External Adapters: Third-party integrations (e.g. Stripe)
- ORM/Query Builders: Data persistence
export class PostgreSQLUserRepository implements UserRepository { constructor(private readonly db: Database) {} async findById(id: UserId): Promise<User | null> { const row = await this.db.query("SELECT * FROM users WHERE id = $1", [ id.toString(), ]); return row ? this.toDomain(row) : null; } async save(user: User): Promise<void> { await this.db.query( `INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET email = $2`, [user.id.toString(), user.email.toString(), user.passwordHash], ); } private toDomain(row: UserRow): User { return new User( UserId.fromString(row.id), PasswordHash.fromString(row.password_hash), ); } }
Presentation Layer
Entry points to the application — controllers, resolvers, CLI commands. A useful pattern is using filter objects as parameters for queries and mutations, so you only need to change the filter object rather than method signatures.
// GraphQL public async Task<User?> GetCurrentUser(UserContext userContext, UserRepository userRepo) { return await userRepo.GetByAsync(new UserFilter { Id = userContext.UserId }); } // Repository with filter pattern public async Task<User?> GetByAsync(UserFilter filter) { var query = _db.Users.AsQueryable(); if (filter.Id.HasValue) query = query.Where(u => u.Id == filter.Id.Value); if (!string.IsNullOrEmpty(filter.Email)) query = query.Where(u => u.Email == filter.Email); return await query.FirstOrDefaultAsync(); }
Middleware
Checklist:
- Does it touch Domain logic? It shouldn't. Move that logic to a Service.
- Does it use abstractions? Use
ILoggerorICurrentUserinstead of hardcoding logic. - Is it outer circle only?
HttpContextshould never leak into your Application project.
public async Task InvokeAsync(HttpContext context) { var logger = context.RequestServices.GetRequiredService<IHttpDetailedLogger>(); await logger.LogRequestAsync(context.Request); await _next(context); await logger.LogResponseAsync(context.Response); }
DTOs and Folder Structure
Avoid the "Grab Bag" antipattern — one folder bundling everything related to a concept (like having all repositories in one folder). Instead, organize by feature:
Application/ ├── Features/ │ └── AgentLogs/ │ ├── Queries/ │ │ ├── GetGlobalLogs/ │ │ │ ├── GetGlobalLogsQuery.cs │ │ │ ├── GlobalLogsResponse.cs │ │ │ └── GetGlobalLogsHandler.cs │ ├── Commands/ │ │ ├── AppendLog/ │ │ │ ├── AppendLogCommand.cs │ │ │ └── AppendLogHandler.cs │ └── Mappings/ │ └── AgentLogMapper.cs
Use consistent naming — Input for requests, Response for responses:
public sealed record GlobalLogsInput( string? Search = null, AgentLogType? Type = null, int Skip = 0, int Limit = 50) : IRequest<GlobalLogsResponse>; public sealed record AgentLogResponse( Guid Id, string? AgentName, DateTime Time );
Split Persistence Model and Domain Entity
Domain Entity: Pure logic, private setters, business methods. Persistence Model: Plain class in infrastructure matching the database schema exactly.
Workflow: DB → Persistence Model → Mapping in Repository → Domain Entity (and back).
// Persistence model (Infrastructure) internal class AgentDbModel { public Guid Id { get; set; } public string Name { get; set; } = string.Empty; public int PowerLevel { get; set; } public DateTime LastUpdatedUtc { get; set; } } // Domain entity public class Agent { public Guid Id { get; private set; } public string Name { get; private set; } public int PowerLevel { get; private set; } public void Train(int effort) { if (effort < 0) throw new ArgumentException("Effort must be positive"); PowerLevel += effort; } public static Agent Create(string name) => new() { Id = Guid.NewGuid(), Name = name, PowerLevel = 1 }; private Agent() { } }
Benefits of splitting:
- Freely rename database columns without touching domain logic
- Logic protection via private setters
- A domain model might be stored across 3 tables — abstracting this makes development easier
- Clear visibility into whether a change affects the database, an input, or the domain
Visibility
Visibility enforces the architectural rules. The default should be private. If something is not private, you must answer why.
sealed— prevents inheritanceabstract— must be inherited, for shared logicinit— settable at creation only, great for DTOs
Domain Layer: Entities public, properties public get / private set or init, repository interfaces public.
Application Layer: Interfaces public, DTOs public, implementations internal, mappers internal static.
Infrastructure: Everything internal — database, repository implementations, data models, third-party adapters.
If repositories are internal, the API registers them via a public static extension method in the Infrastructure project (e.g. AddInfrastructure()). Each project owns its service registration internally and exposes only a public method for it.
Namespaces
Keep namespaces flat. Max depth of 2 (e.g. Interfaces.Skills, never Interfaces.Skills.Agent). Library imports go into global usings; internal namespace imports stay in each file to show the relationship.
Testing
<ItemGroup> <InternalsVisibleTo Include="EnterpriseAgentOs.UnitTests" /> </ItemGroup>
This lets your test project access internal types without making them public.