An educational ASP.NET Core Web API that demonstrates best practices for consuming external APIs without compromising your application's stability. It integrates with the public ViaCEP API to look up Brazilian ZIP codes, applying resilience techniques, local caching, and complete call auditing.
- About
- Features
- Tech Stack
- Architecture
- Project Structure
- Getting Started
- API Reference
- Database Schema
- Testing
- Concepts Practiced
- Roadmap
- Author
This project was built to illustrate how to safely integrate a third-party API into a production-ready service. The key goals are:
- Never crash when the external API is offline or returns unexpected data
- Never hang when the external API is slow
- Reduce external calls with intelligent local caching
- Audit every call for observability and debugging
- Return consistent, informative errors to consumers
- ZIP code lookup via the ViaCEP public API
- Cache-aside — results are stored in MySQL and served from cache on subsequent requests
- Retry policy — up to 3 retries with exponential backoff (2 s, 4 s, 8 s) via Polly
- 10-second HTTP timeout to prevent the app from hanging
- Full audit log of every external call (status code, response time, content)
- Input validation — rejects anything that isn't an 8-digit ZIP code
- Standardized envelope — every response uses
ApiResponse<T>with a UTC timestamp - Swagger / OpenAPI docs available in development
| Layer | Technology |
|---|---|
| Runtime | .NET 8.0, ASP.NET Core Web API |
| ORM | Entity Framework Core 8.0 (Pomelo MySQL provider) |
| Database | MySQL 8.0 |
| Resilience | Polly 10 (WaitAndRetryAsync, HTTP timeout) |
| Mapping | AutoMapper 12 |
| Docs | Swashbuckle / Swagger |
Client
│
▼
CepController (validates input, maps exceptions → HTTP status)
│
▼
CepService
├─► MySQL (cache hit?) ──yes──► return cached result
│
└─► ViaCEP API ──────────────► store in MySQL + audit log ──► return result
│
└── on failure ────────► audit log ──► throw (503)
| Pattern | Where |
|---|---|
| Cache-Aside | CepService checks MySQL before calling ViaCEP |
| Audit Log | Every external call is recorded in ExternalApiCalls |
| DTO | CepResponseDTO, ViaCepResponseDTO keep entities internal |
| Response Envelope | ApiResponse<T> wraps every response with success, data, error, timestamp |
| Dependency Injection | ICepService injected into the controller |
// HttpClient with 10-second timeout + 3 retries (exponential backoff)
builder.Services.AddHttpClient<ICepService, CepService>(client =>
{
client.Timeout = TimeSpan.FromSeconds(10);
}).AddTransientHttpErrorPolicy(policy =>
policy.WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)))
);Retry wait times: 2 s → 4 s → 8 s (total worst-case: ~24 s before giving up).
ApiConsumer/
├── Controllers/
│ └── CepController.cs # GET /api/cep/{cep}
├── Services/
│ └── CepService.cs # Business logic, caching, ViaCEP call
├── Interfaces/
│ └── ICepService.cs # Service contract
├── Context/
│ ├── AppDbContext.cs # EF Core DbContext
│ └── AppDbContextFactory.cs # Design-time factory for migrations
├── Entities/
│ ├── Cep.cs # Cached ZIP code record
│ └── ExternalApiCall.cs # Audit log entry
├── DTOs/
│ ├── Responses/
│ │ ├── ApiResponse.cs # Generic response envelope
│ │ ├── ApiErrorResponse.cs # Error payload
│ │ └── CepResponseDTO.cs # Public address DTO
│ └── External/
│ └── ViaCepResponseDTO.cs # ViaCEP API deserialization DTO
├── Mappings/
│ └── CepProfile.cs # AutoMapper: Cep → CepResponseDTO
└── Migrations/ # EF Core migrations
- .NET SDK 8
- MySQL 8.0 (local install or Docker)
- Git
git clone https://github.com/renanzitoo/ApiConsumer.git
cd ApiConsumerOption A — local MySQL: edit ApiConsumer/appsettings.json:
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Port=3306;Database=apiconsumer;User=root;Password=your-password;"
}
}Option B — Docker:
docker run --name mysql-apiconsumer \
-e MYSQL_ROOT_PASSWORD=root \
-e MYSQL_DATABASE=apiconsumer \
-p 3306:3306 -d mysql:8.0dotnet ef database update --project ApiConsumerdotnet run --project ApiConsumerThe API will be available at https://localhost:5001 (or http://localhost:5000).
Swagger UI: https://localhost:5001/swagger
Looks up a ZIP code. Accepts the code with or without a hyphen (01001000 or 01001-000).
| Status | Code | Meaning |
|---|---|---|
200 OK |
— | ZIP code found (possibly from cache) |
400 Bad Request |
INVALID_CEP |
Missing or non-8-digit input |
404 Not Found |
CEP_NOT_FOUND |
ZIP code does not exist in ViaCEP |
503 Service Unavailable |
CEP_SERVICE_UNAVAILABLE |
ViaCEP unreachable after retries |
GET /api/cep/01001000{
"success": true,
"data": {
"cepCode": "01001000",
"street": "Praça da Sé",
"neighborhood": "Sé",
"city": "São Paulo",
"state": "SP",
"uf": "SP"
},
"error": null,
"timestamp": "2026-03-16T12:00:00.000Z"
}GET /api/cep/123{
"success": false,
"data": null,
"error": {
"code": "INVALID_CEP",
"message": "CEP inválido. Informe 8 dígitos."
},
"timestamp": "2026-03-16T12:00:00.000Z"
}GET /api/cep/99999999{
"success": false,
"data": null,
"error": {
"code": "CEP_NOT_FOUND",
"message": "CEP não encontrado."
},
"timestamp": "2026-03-16T12:00:00.000Z"
}{
"success": false,
"data": null,
"error": {
"code": "CEP_SERVICE_UNAVAILABLE",
"message": "Serviço de CEP indisponível no momento."
},
"timestamp": "2026-03-16T12:00:00.000Z"
}| Column | Type | Notes |
|---|---|---|
Id |
CHAR(36) | Primary key (GUID) |
CepCode |
VARCHAR(8) | Normalized (digits only) |
Street |
VARCHAR(200) | |
Neighborhood |
VARCHAR(100) | |
City |
VARCHAR(100) | |
State |
VARCHAR(100) | |
UF |
VARCHAR(2) | State abbreviation |
CreatedAt |
DATETIME | |
UpdatedAt |
DATETIME |
| Column | Type | Notes |
|---|---|---|
Id |
CHAR(36) | Primary key (GUID) |
Provider |
VARCHAR(50) | e.g. ViaCEP |
Endpoint |
VARCHAR(500) | Full URL called |
RequestKey |
VARCHAR(100) | ZIP code used |
Success |
BOOLEAN | |
ResponseStatusCode |
INT | HTTP status |
ResponseContent |
TEXT | Raw response body |
ResponseTimeInMilliseconds |
INT | |
RequestedAt |
DATETIME |
Open https://localhost:5001/swagger in a browser.
curl -X GET "https://localhost:5001/api/cep/01001000" \
-H "Accept: application/json"Invoke-RestMethod -Uri "https://localhost:5001/api/cep/01001000" -Method Getconst res = await fetch('https://localhost:5001/api/cep/01001000');
const data = await res.json();
console.log(data);- External API consumption with typed
HttpClient - Resilience: timeout + retry with exponential backoff (Polly)
- Cache-Aside pattern to reduce upstream dependency
- Audit logging that never breaks the main flow
async/awaitthroughout- Entity Framework Core with MySQL (Pomelo provider)
- AutoMapper for clean DTO separation
- Input validation and domain exceptions
- Standardized API response envelope
- Structured logging with
ILogger/ Serilog - Circuit Breaker with Polly
- Unit tests (xUnit + Moq)
- Integration tests (WebApplicationFactory)
- Health checks (
/healthz) - Rate limiting
- Prometheus metrics
- Docker Compose setup
- CI/CD with GitHub Actions
Renan Costa — github.com/renanzitoo
If this project was useful, consider giving it a ⭐