AdsServingService - Service Documentation
1. Overview
Purpose: Real-time ad serving microservice that implements a second-price auction system. It receives ad serve requests, runs real-time bidding (RTB) auctions among eligible ad candidates, determines winners via eCPM scoring, and tracks impressions/clicks. It also provides admin endpoints for monitoring auctions, budget pacing, and frequency capping.
Port: 5012 (Development, via launchSettings.json)
Docker Port: 8080 (Production, via ASPNETCORE_URLS=http://+:8080)
Database: ads_serving_service (PostgreSQL, Neon cloud in appsettings.json)
Framework: .NET 10.0, C# 14
Solution File: AdsServingService.slnx
Initial Migration: 20260117181413_InitialCreate
2. API Endpoints
AdsController (api/v1/ads)
| Method |
Route |
Description |
Request Body |
Response |
| POST |
/api/v1/ads/serve |
Serve an ad via real-time auction (target < 100ms) |
ServeAdRequest |
200 OK with ServedAdDto or 204 NoContent if no eligible ads |
| POST |
/api/v1/ads/events/impression |
Track ad impression (fire-and-forget) |
ImpressionEvent |
202 Accepted |
| POST |
/api/v1/ads/events/click |
Track ad click (fire-and-forget) |
ClickEvent |
202 Accepted |
Request/Response DTOs (AdsController):
AdminAuctionsController (api/v1/admin/auctions)
| Method |
Route |
Description |
Query Params |
Response |
| GET |
/api/v1/admin/auctions |
Paginated list of auctions with filters |
userId, placementType, startDate, endDate, page (default 1), pageSize (default 20) |
200 OK with PagedResult<AuctionDto> |
| GET |
/api/v1/admin/auctions/statistics |
Aggregate auction statistics |
none |
200 OK with AuctionStatisticsDto |
AdminBudgetController (api/v1/admin/budget)
| Method |
Route |
Description |
Query Params |
Response |
| GET |
/api/v1/admin/budget/pacers |
Paginated list of budget pacers |
campaignId, page (default 1), pageSize (default 20) |
200 OK with PagedResult<BudgetPacerDto> |
| GET |
/api/v1/admin/budget/campaigns/{campaignId} |
Budget status for a specific campaign |
none |
200 OK with BudgetPacerDto or 404 |
| PUT |
/api/v1/admin/budget/campaigns/{campaignId}/reset |
Reset daily spend for a campaign |
none |
200 OK or 404 |
| GET |
/api/v1/admin/budget/statistics |
Budget utilization statistics |
none |
200 OK with BudgetStatisticsDto |
AdminFrequencyController (api/v1/admin/frequency)
| Method |
Route |
Description |
Query Params |
Response |
| GET |
/api/v1/admin/frequency/caps |
List frequency caps |
adId (optional filter) |
200 OK with List<FrequencyCapDto> |
| GET |
/api/v1/admin/frequency/caps/{id} |
Get frequency cap by ID |
none |
200 OK with FrequencyCapDto or 404 |
| POST |
/api/v1/admin/frequency/caps |
Create a new frequency cap |
body: CreateFrequencyCapRequest |
201 Created with FrequencyCapDto |
| DELETE |
/api/v1/admin/frequency/caps/{id} |
Delete a frequency cap |
none |
204 NoContent or 404 |
Admin DTOs:
Health Check Endpoints
| Route |
Description |
/health |
Full health check (includes PostgreSQL) |
/health/live |
Liveness probe (app is running, no dependency checks) |
/health/ready |
Readiness probe (includes PostgreSQL check) |
3. Commands
No MediatR Commands exist in this service. The service is primarily read/query-oriented. Write operations (frequency cap CRUD, budget reset) are handled directly in controllers via DbContext without MediatR commands.
4. Queries
ServeAdQuery
- File:
Application/Queries/ServeAdQuery.cs
- Parameters:
UserId (Guid), PlacementType (string), UserContext (Dictionary<string, string>)
- Returns:
ServedAdDto? (nullable - returns null if no eligible ads or auction fails)
- Handler:
ServeAdQueryHandler - orchestrates the full RTB auction pipeline:
- Fetches eligible ads via
IEligibleAdsProvider
- Creates an
Auction domain entity
- Scores each candidate via
IAuctionScoringService (predicted CTR + quality score)
- Adds bids to auction
- Runs second-price auction (winner determined by highest eCPM)
- Persists auction to database (bids stored as JSONB)
- Publishes
AdServedEvent via IAdServingEventPublisher
- Returns winning ad details with final price
GetAuctionsQuery
- File:
Application/Queries/GetAuctionsQuery.cs
- Parameters:
UserId? (Guid), PlacementType? (string), StartDate? (DateTime), EndDate? (DateTime), Page (int, default 1), PageSize (int, default 20)
- Returns:
PagedResult<AuctionDto> - paginated auction history with bid counts parsed from JSONB
- Handler:
GetAuctionsQueryHandler - queries auctions from DB with optional filters, ordered by auction_time descending
GetAuctionStatisticsQuery
- File:
Application/Queries/GetAuctionStatisticsQuery.cs
- Parameters: none
- Returns:
AuctionStatisticsDto with TotalAuctions, AverageWinRate (%), AverageeCPM, TotalBidsPlaced
- Handler:
GetAuctionStatisticsQueryHandler - loads all auctions into memory and computes aggregates (bid counts parsed from JSONB)
GetBudgetPacersQuery
- File:
Application/Queries/GetBudgetPacersQuery.cs
- Parameters:
CampaignId? (Guid), Page (int, default 1), PageSize (int, default 20)
- Returns:
PagedResult<BudgetPacerDto> - paginated budget pacer list ordered by last_updated descending
- Handler:
GetBudgetPacersQueryHandler
5. Domain Model
Aggregates
Auction (AuctionAggregate)
- File:
Domain/AggregatesModel/AuctionAggregate/Auction.cs
- Type: Entity + IAggregateRoot
- Fields:
_bids (List), _result (AuctionResult?), _auctionTime (DateTime)
- Public Properties:
UserId, PlacementType, Bids (IReadOnlyCollection), Result, AuctionTime
- Constructor:
Auction(Guid userId, string placementType) - generates new Id, sets auction time to UTC now
- Behavior Methods:
AddBid(adId, campaignId, bidAmount, predictedCTR, qualityScore) - adds a Bid to the auction
RunAuction() - sorts bids by eCPM descending, selects winner, calculates second-price (second-highest eCPM + 0.01)
Bid (child entity of Auction)
- Type: Entity (not aggregate root)
- Properties:
AdId, CampaignId, BidAmount, PredictedCTR, QualityScore
- Computed:
eCPM = BidAmount * PredictedCTR * QualityScore
- Note: Bids are stored as JSONB in the auctions table, not as a separate table. The
Bids navigation is ignored by EF Core.
FrequencyCap (FrequencyAggregate)
- File:
Domain/AggregatesModel/FrequencyAggregate/FrequencyCap.cs
- Type: Entity + IAggregateRoot
- Fields:
_adId (Guid), _maxImpressionsPerUser (int), _window (FrequencyWindow)
- Constructor:
FrequencyCap(Guid adId, int maxImpressionsPerUser, FrequencyWindow window) - validates maxImpressions > 0
- Behavior Methods:
IsUserCapped(int currentImpressions) - returns true if user has reached the cap
Daily(Guid adId, int maxImpressions) - static factory for daily caps
BudgetPacer (PacingAggregate)
- File:
Domain/AggregatesModel/PacingAggregate/BudgetPacer.cs
- Type: Entity + IAggregateRoot
- Fields:
_campaignId (Guid), _dailyBudget (decimal), _spentToday (decimal), _strategy (PacingStrategy), _lastUpdated (DateTime)
- Computed Properties:
RemainingBudget = _dailyBudget - _spentToday, UtilizationPercent = (_spentToday / _dailyBudget) * 100
- Constructor:
BudgetPacer(Guid campaignId, decimal dailyBudget, PacingStrategy strategy) - validates dailyBudget > 0
- Behavior Methods:
CanServeAd(decimal estimatedCost) - checks budget availability; Smooth strategy enforces hourly pacing with 20% buffer
RecordSpend(decimal amount) - adds spend, validates non-negative
ResetDailySpend() - resets spent to 0
Value Objects
AuctionResult
- Properties:
WinningAdId (Guid), WinningCampaignId (Guid), FinalPrice (decimal), WinningeCPM (decimal)
- Equality: based on WinningAdId + FinalPrice
- Mapped as: Owned type within Auction table
UserAdHistory
- Properties:
UserId (Guid), AdImpressionCounts (Dictionary<Guid, int>), Date (DateTime)
- Methods:
RecordImpression(Guid adId), GetImpressionCount(Guid adId)
- Note: Designed for Redis caching (key format
freq:{userId}:{date}), not persisted in PostgreSQL
Enumerations
FrequencyWindow (enum)
Hour = 1, Day = 2, Week = 3, Month = 4, Lifetime = 5
PacingStrategy (enum)
Smooth = 1 (spread evenly throughout the day)
Accelerated = 2 (spend as fast as possible)
Exceptions
- AdsServingDomainException - business rule violations specific to ads serving
- DomainException - generic domain exception base class
SeedWork (Base Classes)
Entity - base entity with Id (Guid), DomainEvents (IReadOnlyCollection), equality by Id
ValueObject - immutable value comparison base
Enumeration - type-safe enum pattern with parse/lookup
IAggregateRoot - marker interface
IRepository<T> - generic repository with IUnitOfWork
IUnitOfWork - SaveChangesAsync(), SaveEntitiesAsync() (dispatches domain events)
6. Database Schema
Database: PostgreSQL (Neon cloud)
Migration: 20260117181413_InitialCreate
Table: auctions
| Column |
Type |
Nullable |
Description |
id |
uuid |
NOT NULL (PK) |
Auction ID |
user_id |
uuid |
NOT NULL |
User who triggered the ad request |
placement_type |
varchar(50) |
NOT NULL |
Ad placement type (feed, story, banner) |
auction_time |
timestamp with time zone |
NOT NULL |
When the auction occurred |
bids |
jsonb |
NULL |
All bids serialized as JSON array |
winning_ad_id |
uuid |
NULL |
Winning ad ID (owned AuctionResult) |
winning_campaign_id |
uuid |
NULL |
Winning campaign ID (owned AuctionResult) |
final_price |
numeric(18,4) |
NULL |
Second-price auction final price (owned AuctionResult) |
winning_ecpm |
numeric(18,4) |
NULL |
Winning bid eCPM (owned AuctionResult) |
Indexes:
ix_auctions_user_id on user_id
ix_auctions_placement_type on placement_type
ix_auctions_auction_time on auction_time
ix_auctions_placement_user composite on (placement_type, user_id)
Table: budget_pacers
| Column |
Type |
Nullable |
Description |
id |
uuid |
NOT NULL (PK) |
Pacer ID |
campaign_id |
uuid |
NOT NULL |
Associated campaign ID |
daily_budget |
numeric(18,4) |
NOT NULL |
Daily budget limit |
spent_today |
numeric(18,4) |
NOT NULL |
Amount spent today |
strategy |
varchar(20) |
NOT NULL |
Pacing strategy ("Smooth" or "Accelerated") |
last_updated |
timestamp with time zone |
NOT NULL |
Last update timestamp |
Indexes:
ix_budget_pacers_campaign_id UNIQUE on campaign_id
ix_budget_pacers_last_updated on last_updated
Table: frequency_caps
| Column |
Type |
Nullable |
Description |
id |
uuid |
NOT NULL (PK) |
Frequency cap ID |
ad_id |
uuid |
NOT NULL |
Associated ad ID |
max_impressions_per_user |
integer |
NOT NULL |
Max impressions per user in window |
window |
varchar(20) |
NOT NULL |
Time window ("Hour", "Day", "Week", "Month", "Lifetime") |
Indexes:
ix_frequency_caps_ad_id on ad_id
7. Integration Events
The service defines an event publishing abstraction (IAdServingEventPublisher) with three event types. The current implementation (LoggingAdServingEventPublisher) only logs events -- no actual message broker integration exists yet.
Published Events
| Event |
Topic/Key |
Payload Fields |
AdServedEvent |
ad.served.v1 |
AuctionId, AdId, CampaignId, UserId, PlacementType, FinalPrice, WinningEcpm, ServedAt |
AdImpressionTrackedEvent |
ads.impression.tracked.v1 |
AdId, UserId, Timestamp |
AdClickTrackedEvent |
ads.click.tracked.v1 |
AdId, UserId, Timestamp |
Consumed Events
None. The service does not consume any external events.
Application Services
IEligibleAdsProvider / InMemoryEligibleAdsProvider
- Returns eligible ad candidates for a serve request
- Current implementation: Returns 2 hardcoded in-memory candidates with deterministic IDs (based on userId + placementType hash)
- Candidate fields: AdId, CampaignId, BidAmount, Format, Headline, PrimaryText, CallToAction, CreativeUrl, DestinationUrl
- Note: This is a stub. Production would integrate with ads-manager-service to fetch real campaigns/ads.
IAuctionScoringService / DefaultAuctionScoringService
- Scores ad candidates to produce
AuctionBidSignals(PredictedCtr, QualityScore)
- Predicted CTR heuristic: base CTR by placement type (story=0.018, banner=0.012, default=0.020), boosted by user segment ("high-intent" +25%) and recency (purchase within 14 days +10%)
- Quality Score heuristic: by format (single_image=1.0, video=1.1, default=0.95)
8. Dependencies
NuGet Packages
API Layer:
| Package |
Version |
| MediatR |
12.4.1 |
| FluentValidation |
11.11.0 |
| FluentValidation.DependencyInjectionExtensions |
11.11.0 |
| Swashbuckle.AspNetCore |
7.2.0 |
| Asp.Versioning.Mvc |
8.1.0 |
| Asp.Versioning.Mvc.ApiExplorer |
8.1.0 |
| AspNetCore.HealthChecks.NpgSql |
8.0.2 |
| AspNetCore.HealthChecks.Redis |
8.0.1 |
| Hellang.Middleware.ProblemDetails |
6.5.1 |
| Serilog.AspNetCore |
8.0.3 |
| Serilog.Sinks.Console |
6.0.0 |
| Serilog.Sinks.Seq |
8.0.0 |
| Microsoft.EntityFrameworkCore.Design |
10.0.1 |
Domain Layer:
| Package |
Version |
| MediatR.Contracts |
2.0.1 |
Infrastructure Layer:
| Package |
Version |
| Microsoft.EntityFrameworkCore |
10.0.0 |
| Npgsql.EntityFrameworkCore.PostgreSQL |
10.0.0 |
| Microsoft.EntityFrameworkCore.Tools |
10.0.0 |
| MediatR |
12.4.1 |
| Dapper |
2.1.35 |
| Microsoft.Extensions.Http.Polly |
9.0.0 |
| Polly |
8.5.0 |
| StackExchange.Redis |
2.8.16 |
External Service Dependencies
- PostgreSQL (Neon cloud) - primary data store
- Redis (configured but not actively used in code) - intended for frequency cap caching (UserAdHistory)
- ads-manager-service (not integrated yet) - intended source for eligible ad campaigns (currently stubbed with InMemoryEligibleAdsProvider)
- ads-tracking-service (not integrated yet) - intended consumer of impression/click events (currently logged only)
9. Configuration
Connection Strings (appsettings.json)
| Key |
Description |
ConnectionStrings:DefaultConnection |
PostgreSQL connection string (Neon cloud endpoint in default config) |
DATABASE_URL |
Alternative env var for connection string (fallback) |
Redis
| Key |
Default |
Redis:ConnectionString |
localhost:6379 |
JWT (configured but not enforced -- no [Authorize] attributes on controllers)
| Key |
Default |
Jwt:Secret |
your-super-secret-key-min-32-characters |
Jwt:Issuer |
goodgo-platform |
Jwt:Audience |
goodgo-services |
Jwt:AccessTokenExpiryMinutes |
15 |
Jwt:RefreshTokenExpiryDays |
7 |
Environment Variables (.env.example)
| Variable |
Description |
ASPNETCORE_ENVIRONMENT |
Runtime environment (Development/Production) |
DATABASE_URL |
PostgreSQL connection string |
REDIS_URL |
Redis connection string |
REDIS_PASSWORD |
Redis password |
JWT_SECRET |
JWT signing key |
API_PORT |
API listening port |
OTEL_EXPORTER_OTLP_ENDPOINT |
OpenTelemetry exporter endpoint |
SEQ_URL |
Seq logging endpoint |
RATE_LIMIT_PERMITS_PER_MINUTE |
Rate limiting config |
Build Configuration (Directory.Build.props)
- Target Framework:
net10.0
- Language Version: C# 14
- Nullable: enabled
- TreatWarningsAsErrors: true
- GenerateDocumentationFile: true
Docker
- Multi-stage build:
sdk:10.0 (build) -> aspnet:10.0 (runtime)
- Non-root user:
dotnetuser (UID/GID 1001)
- Port: 8080
- Health check:
curl -f http://localhost:8080/health/live (30s interval, 3 retries)
Startup Behavior
- Auto-applies EF Core migrations on startup (catches and logs errors without crashing)
- Swagger UI enabled in Development at
/swagger
- CORS: allows any origin/method/header (open)
- MediatR pipeline: LoggingBehavior -> ValidatorBehavior -> TransactionBehavior (skips transactions for queries)
10. Tests
Functional Tests (tests/AdsServingService.FunctionalTests/)
Uses CustomWebApplicationFactory with InMemoryDatabase (unique DB per factory instance).
| Test |
Description |
ServeAd_ShouldReturnServedAdPayload |
POST /api/v1/ads/serve returns 200 with valid adId and positive finalPrice |
GetAuctionStatistics_AfterServe_ShouldReflectPersistedAuctions |
After serving an ad, statistics show >= 1 auction and >= 2 bids |
TrackImpression_ShouldReturnAccepted |
POST /api/v1/ads/events/impression returns 202 Accepted |
HealthCheck_ShouldReturnHealthy |
GET /health/live returns 200 |
Unit Tests (tests/AdsServingService.UnitTests/)
Project exists but contains no test files yet (only generated assembly info).
11. Notable Architecture Decisions
- Second-Price Auction: Winner pays second-highest eCPM + $0.01 (standard RTB model)
- eCPM Formula:
BidAmount * PredictedCTR * QualityScore
- Bids stored as JSONB: Instead of a separate
bids table, bids are serialized to JSON in the auctions.bids column for high-throughput performance. EF Core OwnsMany + ToJson was avoided due to EF Core 10 compatibility issues.
- No authentication: Controllers have no
[Authorize] attributes despite JWT being configured
- Stub eligible ads provider:
InMemoryEligibleAdsProvider returns hardcoded candidates -- needs real integration with ads-manager-service
- Logging-only event publisher: Events are logged but not published to RabbitMQ or any message broker
- No repositories: The service accesses
AdsServingServiceContext directly in handlers and controllers (repository pattern is commented out in DependencyInjection.cs)
- Idempotency infrastructure:
IRequestManager / RequestManager / ClientRequest exist but are not used by any command or controller