Skip to main content

Command Palette

Search for a command to run...

GeoGuard: Building a High-Performance IP Geolocation & Country Blocking API

GeoGuard is a .NET 8 microservice for IP geolocation and country blocking. Built with Clean Architecture, thread-safe in-memory storage, and the Result pattern — no database required. Here's the full design breakdown.

GeoGuard: Building a High-Performance IP Geolocation & Country Blocking API

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:

csharp

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>:

csharp

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:

csharp

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:

  1. Accept the HTTP request

  2. Delegate to domain services

  3. 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:

json

or

json

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:

csharp

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:

  1. Start with the domain, not the database. When you model the business rules correctly first, infrastructure becomes an implementation detail.

  2. 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.

  3. 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.

  4. 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.

0 comments · 0 replies