Skip to content

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.

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