13: Application Architecture for Search.Api & Checkin.Api¶
This section describes how to structure and implement your .NET 9 microservices (Search.Api and Checkin.Api) using Clean Architecture, OpenTelemetry, Redis, RabbitMQ, and PostgreSQL in a way that is scalable, testable, and aligned with modern cloud-native design.
This is one of the longest and most important chapters because it defines how your applications behave internally, how they interact with the cluster, and how they keep consistent structure across all services.
13.1 Clean Architecture Overview¶
Clean Architecture ensures:
• Independence from frameworks
• Independence from UI / API layers
• Testability
• Maintainability
• Clear boundaries between logic, storage, and infrastructure
We use the classic onion-style layering adapted for microservices:
Presentation Layer → Controllers (minimal endpoints in .NET)
Application Layer → Services / Commands / Queries
Domain Layer → Entities / Value Objects / Rules
Infrastructure Layer → PostgreSQL, Redis, RabbitMQ, external APIs
Why Clean Architecture for LocalCloudLab?¶
✓ Each microservice can scale independently
✓ Code becomes predictable between Search.Api and Checkin.Api
✓ Infrastructure (PostgreSQL, Redis, RabbitMQ) can change without rewriting business logic
✓ Testing is dramatically easier
✓ Tracing (OpenTelemetry) becomes structured and uniform
✓ You gain long-term maintainability as the "book" evolves
This structure is used by top software companies building distributed systems.
13.2 Recommended Folder Structure for .NET 9 APIs¶
Each API should follow the SAME structure. Predictability is power.
Inside Search.Api/ and Checkin.Api/ we use:
/Search.Api
│
├── Controllers/ # Presentation layer (endpoints)
├── Application/ # Use cases, services, commands, queries
│ ├── Interfaces/ # Abstractions for DI
│ ├── Services/
│ └── Models/
│
├── Domain/ # Entities, enums, rules
│ ├── Entities/
│ ├── ValueObjects/
│ └── Events/
│
├── Infrastructure/ # Implementation details
│ ├── Persistence/ # EF Core, PostgreSQL
│ ├── Caching/ # Redis implementations
│ ├── Messaging/ # RabbitMQ publishers/consumers
│ ├── Configuration/
│ ├── Mappings/ # DTO → Domain → DTO mappings
│ └── Extensions/
│
├── Telemetry/ # OTel: Tracing, Metrics, Logging
│
├── Workers/ # Background workers (RabbitMQ consumers)
│
├── appsettings.json
├── appsettings.Development.json
└── Program.cs
Search.Api and Checkin.Api MUST mirror each other 1:1. Only domain logic inside Application/Domain changes.
13.3 Dependency Injection Layout¶
In Clean Architecture:
Controllers → Application Layer (Interfaces) → Domain Logic
↓
Infrastructure implements Interfaces
This ensures:
• Controllers NEVER depend on infrastructure directly
• Domain has zero dependencies on external packages
• Infrastructure can be replaced (e.g., PostgreSQL → MongoDB) without modifying business rules
Program.cs registration structure¶
builder.Services.AddControllers();
// Application Layer
builder.Services.AddScoped<ISearchService, SearchService>();
builder.Services.AddScoped<ICheckinValidator, CheckinValidator>();
// Infrastructure Layer
builder.Services.AddDbContext<AppDbContext>(...);
builder.Services.AddSingleton<IConnectionMultiplexer>(...); // Redis
builder.Services.AddSingleton<RabbitMqPublisher>();
builder.Services.AddHostedService<RabbitMqConsumerWorker>();
// OpenTelemetry
builder.Services.AddOpenTelemetry()
.WithTracing(...)
.WithMetrics(...);
The Application Layer should ONLY depend on:¶
• Domain layer
• Interfaces it defines
• Standard .NET packages
• No infrastructure implementations
13.4 DTOs vs Entities vs Domain Models¶
A common mistake: using DB models as API responses. This is forbidden in Clean Architecture.
We use:
DTOs → Represent data crossing API boundaries
Domain Models → Business logic objects (pure logic)
Entities → Stored objects (DB-facing)
View Models → Some APIs require presentational shaping
Transformation flow:¶
Controller Receives DTO
↓
Application Layer converts DTO → Domain Model
↓
Domain Logic executes
↓
Output converted Domain → DTO
↓
Controller returns response
This avoids:
✗ leaking database schema to API consumers
✗ exposing internal rules
✗ tight coupling
13.5 PostgreSQL Integration Layer (EF Core)¶
Inside Infrastructure/Persistence:
AppDbContext.cs
RepositoryImplementations/
EntityConfigurations/
Why EF Core for LocalCloudLab?¶
✓ Works with PostgreSQL
✓ OpenTelemetry instrumentation available
✓ Supports migrations
✓ Ideal for CRUD-heavy services like Search & Checkin
PostgreSQL Best Practices¶
• Use async everywhere
• Avoid tracking when not needed
• Use AddDbContextPool for performance
• Add execution timeout
• Add connection resiliency (EnableRetryOnFailure)
• Keep queries simple (LocalCloudLab is CRUD oriented)
Example in Program.cs:
builder.Services.AddDbContextPool<AppDbContext>(options =>
options.UseNpgsql(connectionString, npgsqlOptions =>
{
npgsqlOptions.EnableRetryOnFailure();
})
.EnableSensitiveDataLogging(false)
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));
EF Core instrumentation for OpenTelemetry¶
.AddEntityFrameworkCoreInstrumentation()
This ensures your DB queries appear in Jaeger and Grafana Tempo.
13.6 Redis Cache Layer¶
Inside Infrastructure/Caching:
RedisCacheService.cs
RedisExtensions.cs
Redis is used for:
• Search results caching
• Checkin session caching
• Temporary tokens
• Rate limiting
• Reducing PostgreSQL pressure
Redis Pattern: Cache Aside¶
1. Check Redis
2. If missing → query database
3. Put result into Redis with TTL
4. Return response
Search API example:
var cacheKey = $"search:{criteriaKey}";
var cached = await redis.GetStringAsync(cacheKey);
if (cached != null)
return JsonSerializer.Deserialize<SearchResponse>(cached);
var data = await _repository.GetSearchResultsAsync(criteria);
await redis.SetStringAsync(cacheKey, JsonSerializer.Serialize(data),
TimeSpan.FromMinutes(5));
Cache invalidation¶
When data changes:
• Publish to Redis Pub/Sub: “search.invalidate”
• Worker removes relevant keys
• Application stays consistent
13.7 RabbitMQ Messaging Layer¶
Inside Infrastructure/Messaging:
RabbitMqPublisher.cs
RabbitMqConsumer.cs
IMessageBus.cs
Message flow for Search API:¶
Search API saves something to DB
↓
Publishes event “search.updated”
↓
RabbitMQ Broker
↓
Worker consumes event
↓
Worker updates cache / triggers background work
Message flow for Checkin API:¶
Checkin created
↓
Publish event → “checkin.created”
↓
Worker sends email / SMS / logs / analytics
RabbitMQ consumers should be:
• Hosted services
• Auto-restarting
• Safe from crash loops
• Acknowledging messages only after success
13.8 OpenTelemetry Integration in the Code¶
Inside Telemetry/TracingExtensions.cs:
Enable tracing for:¶
✓ ASP.NET Core requests
✓ Database queries (EF Core)
✓ HTTP calls
✓ Redis calls
✓ RabbitMQ operations (manual spans)
✓ Custom spans inside application logic
Best practice: Add semantic attributes¶
using var activity = _tracer.StartActivity("search.processing");
activity?.SetTag("search.query", criteria.Query);
activity?.SetTag("search.language", criteria.Language);
Log correlation¶
Ensure:
• trace_id
• span_id
are added to logs.
Using Serilog:
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} | TraceId={TraceId} SpanId={SpanId}{NewLine}"
This allows Grafana, Loki, and Jaeger to correlate logs and traces.
13.9 Health Checks¶
Add:
/health
/health/ready
/health/live
Configure in Program.cs:¶
builder.Services.AddHealthChecks()
.AddNpgSql(connectionString)
.AddRedis(redisConnection)
.AddRabbitMQ(rabbitConnection)
.AddCheck<CustomDependencyCheck>("custom");
Expose:
app.MapHealthChecks("/health");
Envoy Gateway automatically uses this for routing health.
13.10 Configuration Strategy¶
Use:
appsettings.json # defaults
appsettings.Development.json # local
Environment variables # Kubernetes injection
Example:¶
"ConnectionStrings": {
"Postgres": "Host=db;Database=search;Username=...;Password=..."
},
"Redis": {
"Connection": "redis-master.caching.svc.cluster.local:6379"
},
"RabbitMQ": {
"Host": "rabbitmq.messaging.svc.cluster.local"
}
In Kubernetes:
kubectl create secret ...
Then in Deployment:
env:
- name: ConnectionStrings__Postgres
valueFrom:
secretKeyRef:
name: search-api-secret
key: postgres-connection
This ensures NO secrets are ever stored in Git.
13.11 Summary of Section 13¶
In this section you learned:
✔ The full Clean Architecture setup for LocalCloudLab
✔ Predictable folder structure for all .NET APIs
✔ How Application, Domain, and Infrastructure layers interact
✔ How to integrate PostgreSQL, Redis, RabbitMQ
✔ How to implement OpenTelemetry inside your actual code
✔ How to structure caching, messaging, and DB access correctly
✔ How to configure your APIs for Kubernetes the right way
Your APIs are now cloud-native, clean, scalable, and observable.
Next section (Section 14) can focus on:
• Enhancing Domain Models
• Advanced Validation
• Implementing Search and Checkin workflows end-to-end
• Domain Events