The Problem: Why GeoGuard Exists
Every API that serves a global audience eventually faces the same question: "How do we control access based on where the request is coming from?"
Whether you're enforcing compliance (GDPR regional restrictions), preventing fraud (blocking high-risk countries), or simply running A/B tests geo-targeted to specific markets, you need a system that can:
Block countries permanently or for a limited time
Look up the country of any incoming IP address in real-time
Audit every attempt — allowed or blocked — for security review
Survive high concurrency without data corruption or race conditions
The catch? This system needs to be stateless, fast, and lightweight. No heavy database infrastructure. No distributed cache cluster. Just a focused microservice that does one job exceptionally well.
That's GeoGuard.
Architecture Overview
GeoGuard follows Clean Architecture principles with a pragmatic, microservice-oriented mindset. The project is split into four layers, each with a single responsibility:
<image>
Why Clean Architecture for a focused tool? Because even small services grow. The strict boundary between domain logic and infrastructure means I can swap the in-memory store for PostgreSQL tomorrow, or replace IPGeolocation.io with MaxMind, without touching a single line of business logic.
Domain Layer: The Core Rules
The heart of GeoGuard is ruthlessly protective of its invariants. I used Domain-Driven Design (DDD) patterns to ensure invalid data can never exist in the system.
Value Objects: Strong Typing at the Domain Level
Instead of passing raw strings around, GeoGuard uses Value Objects that enforce rules at construction time:
This eliminates an entire class of bugs before they reach the service layer. No more "us" vs "US" vs "USA" inconsistencies.
Entities with Private Setters
Domain entities like BlockedCountry and BlockedAttemptLog use private setters and static factory methods. You can't just new them up and leave them in an invalid state. They must be created through controlled factory methods that enforce business rules.
The Unified Expiration Strategy
One elegant design choice: a single BlockedCountry entity handles both permanent and temporal blocks.
ExpirationTime = null → Permanent block
ExpirationTime = DateTime.UtcNow.AddHours(6) → Temporal block
No separate tables. No polymorphic repositories. One entity, one storage mechanism, infinite flexibility.
The Result Pattern
GeoGuard entirely avoids using exceptions for control flow. Every operation returns a Result<T>:
This standardizes success/failure across the entire codebase and makes controller code incredibly clean — no try/catch spaghetti for business validations.
Infrastructure Layer: Thread-Safe I/O
Since GeoGuard operates without a traditional database, the infrastructure layer had to solve a critical problem: how do you store and query data in memory without race conditions?
ConcurrentDictionary: The Blocked Countries Store
The blocked countries repository is backed by a ConcurrentDictionary<string, BlockedCountry>. This gives us:
O(1) lookups by country code
Thread-safe writes without manual locking
Atomic add-or-update operations
ConcurrentQueue: The Audit Log
Blocked attempts are logged to a ConcurrentQueue<BlockedAttemptLog>. Queues are perfect for audit trails because:
Append-only semantics match the business need
Thread-safe enqueue from multiple concurrent requests
FIFO ordering for chronological review
The Geolocation HTTP Client
GeoGuard integrates with IPGeolocation.io to resolve IP addresses to country codes. Instead of manually instantiating HttpClient (which causes socket exhaustion under load), the service uses IHttpClientFactory:
This provides:
Connection pooling and automatic DNS refresh
Named client configuration per external service
Resilience against TCP socket exhaustion
API Layer: Clean Controllers
GeoGuard's controllers are intentionally dumb traffic cops. They don't contain business logic. They don't validate domain rules. They simply:
Accept the HTTP request
Delegate to domain services
Translate
Result<T>into the appropriate HTTP response
This keeps the API layer thin and ensures that the same business logic could be exposed via gRPC, a CLI tool, or a message queue tomorrow without any code changes.
Global Response Standardization
Using a custom Result extension, every API response follows a predictable schema:
or
This consistency makes frontend integration effortless and debugging trivial.
Swagger as Living Documentation
Every endpoint is documented with XML docstrings that feed directly into Swashbuckle. The result is an interactive Swagger UI at /swagger that surfaces:
Request/response models with full schemas
Expected status codes
Parameter descriptions
No separate documentation to maintain. The code is the documentation.
Key Engineering Decisions
1. No AutoMapper, No FluentValidation
For a focused microservice, these libraries add more ceremony than value. Simple constructors, value objects, and extension methods handle mapping and validation with zero dependencies and zero reflection overhead.
2. Domain as Application Layer
In larger systems, I'd separate Domain and Application into distinct layers with MediatR or similar. For GeoGuard, the use cases are straightforward enough that merging them reduces boilerplate without sacrificing testability. The bracket is open to split them later if complexity grows.
3. In-Memory Over Database
This was a deliberate trade-off. ConcurrentDictionary and ConcurrentQueue provide:
Sub-millisecond reads and writes
Zero network latency
Zero deployment complexity
The cost? Data resets on restart. For GeoGuard's use case — a lightweight access control layer — this is acceptable. If persistence becomes a requirement, the IBlockedCountryRepository interface allows swapping to Redis or PostgreSQL with a single file change.
Resilience & Performance
Rate Limiting
GeoGuard protects both itself and its external API budget with built-in rate limiting. Since the free tier of IPGeolocation.io allows 1,000 requests per day, GeoGuard enforces a 100 requests per minute limit on lookup endpoints. This ensures the service remains operational throughout the day without hitting external quota walls.
Early-Exit Orchestration
The BlockVerificationService handles the complex lookup-and-check flow with strict early exits:
Each step returns immediately if a condition is met. No nested if pyramids. No unnecessary work.
Testing Strategy
GeoGuard includes a comprehensive test suite covering three critical layers:
1. Domain Value Objects
Tests verify that CountryCode, IPAddress, and other value objects enforce their rules correctly. Invalid inputs are rejected at the boundary.
2. Country Management
Tests verify thread-safe dictionary behavior — adding duplicates, respecting storage limits, and handling concurrent modifications without corruption.
3. Block Verification Flow
The core integration point: given an IP address, does the system correctly identify blocked vs. allowed traffic? Tests mock the geolocation service to simulate responses from different countries and verify the audit log captures every attempt.
Testing Stack: xUnit, Moq, FluentAssertions, Coverlet
What I Learned
Building GeoGuard reinforced a few principles I now apply to every project:
Start with the domain, not the database. When you model the business rules correctly first, infrastructure becomes an implementation detail.
Thread safety is not optional. Even "simple" in-memory storage needs Concurrent collections the moment you expose an HTTP endpoint. Race conditions in production are painful; preventing them at the architecture level is cheap.
External APIs are a liability. Rate limits, timeouts, and outages will happen. Design for them from day one with factory-managed HTTP clients, circuit breakers, and graceful fallbacks.
Result > Exceptions for control flow. Exceptions are for exceptional situations — system crashes, network failures. Business rule violations are expected outcomes. Treat them as data, not disasters.
