API Gateway for Beginners: From Basic Routing to Production-Ready Microservices
.NET API Gateway for Beginners: From Basic Routing to Production-Ready Microservices
If you are starting with microservices in .NET, one of the first problems you will encounter is:
"My application has many APIs. How should the frontend communicate with all of them?"
Imagine that your application has these services:
Frontend
|
+----> User Service :5001
|
+----> Product Service :5002
|
+----> Order Service :5003
|
+----> Payment Service :5004
|
+----> Notification :5005
This works, but as the system grows, several problems appear.
The frontend needs to know where every service is located. Authentication may need to be handled by every service. Rate limiting may need to be implemented everywhere. Logging, CORS, security, caching, and other cross-cutting concerns become duplicated.
This is where an API Gateway becomes useful.
1. What Problem Does an API Gateway Solve?
Without a gateway, the frontend communicates directly with every microservice:
+--> User Service
|
Frontend ---------->+--> Product Service
|
+--> Order Service
|
+--> Payment Service
The frontend now needs to know:
- Where each service is running
- Which port each service uses
- How authentication works
- Which service requires which headers
- How to handle failures
- How to handle rate limits
- How to communicate with different services
This creates unnecessary complexity.
With an API Gateway:
+--> User Service
|
Frontend --> Gateway +--> Product Service
|
+--> Order Service
|
+--> Payment Service
The frontend only knows about the gateway.
For example:
https://api.mycompany.com/users
https://api.mycompany.com/products
https://api.mycompany.com/orders
The gateway decides where those requests should go.
So the gateway becomes the front door of your microservices architecture.
2. What Can an API Gateway Do?
An API Gateway can handle many cross-cutting responsibilities:
- Request routing
- Authentication and authorization
- Rate limiting
- Response caching
- CORS
- IP whitelisting
- Circuit breakers
- Health checks
- Logging
- Metrics
- Load balancing
- Request transformation
It can also sit between your public API and internal services.
The important idea is:
Don't start by adding every feature. Start with routing and gradually add the features your system actually needs.
3. Our Example Architecture
Throughout this article, let's imagine we have:
Frontend
|
v
+--------------+
| API Gateway |
| :5000 |
+--------------+
/ | \
/ | \
v v v
Product Order User
:5001 :5002 :5003
We'll use ASP.NET Core + Ocelot for the gateway.
4. Creating the Gateway
Create a Web API project:
dotnet new webapi -n MyGateway
cd MyGateway
Install Ocelot:
dotnet add package Ocelot
If you want Ocelot caching support:
dotnet add package Ocelot.Cache.CacheManager
Ocelot provides the routing/middleware functionality required to build our gateway.
5. Basic Routing
Create an ocelot.json file.
For example:
{
"Routes": [
{
"DownstreamPathTemplate": "/api/products",
"DownstreamScheme": "http",
"DownstreamHostAndPorts": [
{
"Host": "localhost",
"Port": 5001
}
],
"UpstreamPathTemplate": "/products",
"UpstreamHttpMethod": ["GET", "POST"]
},
{
"DownstreamPathTemplate": "/api/orders",
"DownstreamScheme": "http",
"DownstreamHostAndPorts": [
{
"Host": "localhost",
"Port": 5002
}
],
"UpstreamPathTemplate": "/orders",
"UpstreamHttpMethod": ["GET"]
}
],
"GlobalConfiguration": {
"BaseUrl": "http://localhost:5000"
}
}
The important concept here is upstream vs downstream.
Upstream
The URL exposed by the gateway:
/products
Downstream
The actual microservice endpoint:
http://localhost:5001/api/products
Therefore:
GET http://localhost:5000/products
|
v
Gateway
|
v
GET http://localhost:5001/api/products
The frontend doesn't need to know that Product Service is running on port 5001.
6. Basic Program.cs
The initial Program.cs can be very simple:
using Ocelot.DependencyInjection;
using Ocelot.Middleware;
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddJsonFile("ocelot.json");
builder.Services.AddOcelot();
var app = builder.Build();
await app.UseOcelot();
app.Run();
That's enough to get basic routing working.
At this point:
/products --> Product Service
/orders --> Order Service
Your gateway is alive.
But a production gateway needs much more.
7. Environment Configuration
We normally don't want to hard-code production URLs into ocelot.json.
Development might use:
localhost:5001
localhost:5002
Production might use:
product-service
order-service
or Azure/container DNS names.
ASP.NET Core supports environment-specific configuration.
For example:
appsettings.json
appsettings.Development.json
appsettings.Production.json
ocelot.json
ocelot.Development.json
ocelot.Production.json
You can load them like this:
var builder = WebApplication.CreateBuilder(args);
builder.Configuration
.AddJsonFile("appsettings.json", optional: false)
.AddJsonFile(
$"appsettings.{builder.Environment.EnvironmentName}.json",
optional: true)
.AddJsonFile("ocelot.json")
.AddJsonFile(
$"ocelot.{builder.Environment.EnvironmentName}.json",
optional: true)
.AddEnvironmentVariables();
Why AddEnvironmentVariables()?
It allows configuration values to come from environment variables.
For example:
Jwt__Issuer
Jwt__Audience
Jwt__Key
Redis__Connection
can override configuration values.
This is particularly useful in Docker, Kubernetes, and Azure because you don't need to put secrets directly into your source code.
A good rule is:
Configuration can live in files. Secrets should normally come from a secure configuration mechanism or environment/infrastructure configuration.
8. Rate Limiting
Now imagine someone starts sending:
10,000 requests/second
to your API.
Your gateway can protect downstream services using rate limiting.
For example:
{
"RateLimitOptions": {
"EnableRateLimiting": true,
"Period": "1m",
"Limit": 60,
"ClientWhitelist": ["admin"],
"QuotaExceededResponse": {
"StatusCode": 429,
"Content": "{\"error\":\"Rate limit exceeded\"}"
}
}
}
This means a client may only make a certain number of requests within a time period.
When the limit is exceeded:
HTTP 429 Too Many Requests
The gateway stops excessive traffic before it reaches your microservices.
This protects:
Gateway
|
X ---- Too many requests
|
Microservices
instead of allowing the traffic to overload the backend.
9. Caching
Suppose your product catalog changes only every few minutes.
Why call Product Service every time?
Client 1 --> Product Service
Client 2 --> Product Service
Client 3 --> Product Service
Client 4 --> Product Service
Instead, the gateway can cache responses.
For example:
{
"FileCacheOptions": {
"TtlSeconds": 60,
"Region": "product-catalog"
}
}
Now:
First request
Client --> Gateway --> Product Service
|
v
Cache
Next request
Client --> Gateway --> Cache
The backend doesn't need to process every identical request.
For a single gateway instance, local caching may be enough.
But when you have multiple gateway instances:
Load Balancer
/ | \
v v v
Gateway Gateway Gateway
\ | /
\ | /
Redis
a distributed cache such as Redis becomes much more useful.
10. JWT Authentication
Security is another major responsibility of the gateway.
Suppose a client sends:
Authorization: Bearer eyJhbGciOi...
The gateway can validate the JWT before forwarding the request.
Basic configuration:
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters =
new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidIssuer =
builder.Configuration["Jwt:Issuer"],
ValidAudience =
builder.Configuration["Jwt:Audience"],
IssuerSigningKey =
new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(
builder.Configuration["Jwt:Key"]))
};
});
builder.Services.AddAuthorization();
Then:
app.UseAuthentication();
app.UseAuthorization();
The idea becomes:
Client
|
| JWT
v
Gateway
|
| valid?
|
+---- No ----> 401
|
+---- Yes ---> Microservice
This prevents unauthorized requests from reaching protected services.
11. Health Checks
Your gateway should also expose a simple health endpoint.
For example:
app.MapGet("/health", () =>
Results.Ok(new
{
status = "healthy"
}))
.AllowAnonymous();
Now:
GET /health
returns:
{
"status": "healthy"
}
Why is this useful?
Load balancers, Kubernetes, Azure, monitoring systems, and deployment systems can use health endpoints to determine whether your application is alive.
You can also create detailed health checks for dependencies such as databases, Redis, or other services.
12. IP Whitelisting
Sometimes an endpoint should only be accessible from specific networks or IP addresses.
For example:
Allowed:
10.0.0.10
10.0.0.20
Everything else:
403 Forbidden
A simple middleware example:
app.Use(async (context, next) =>
{
var allowedIPs = new[]
{
"192.168.1.1",
"10.0.0.1"
};
var clientIP =
context.Connection.RemoteIpAddress?.ToString();
if (!allowedIPs.Contains(clientIP))
{
context.Response.StatusCode = 403;
await context.Response.WriteAsync("Access denied");
return;
}
await next();
});
However, in production, be careful with proxies and load balancers because the IP visible to your application may be the proxy's IP rather than the original client's IP.
13. Circuit Breaker
Now imagine your Order Service is down.
Without protection:
Client
|
Gateway
|
Order Service
X
Failure
The gateway keeps sending requests.
Eventually:
100 requests
100 failures
1000 requests
1000 failures
A circuit breaker helps stop repeatedly calling a failing service.
Conceptually:
Healthy
|
failures
v
OPEN
|
stop calling service
|
timeout
v
Half Open
|
test request
/ \
success failure
| |
v v
CLOSED OPEN
With Ocelot QoS configuration:
{
"QoSOptions": {
"ExceptionsAllowedBeforeBreaking": 3,
"DurationOfBreak": 60,
"TimeoutValue": 5000
}
}
Here the gateway can stop calling a service after repeated failures and give the service time to recover.
This is one of the most important resilience concepts in microservices.
14. CORS
If your frontend runs at:
https://myfrontend.com
and your gateway runs at:
https://api.mycompany.com
the browser considers them different origins.
You can configure CORS:
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowFrontend", policy =>
{
policy
.WithOrigins(
"https://myfrontend.com",
"https://localhost:3000")
.AllowAnyMethod()
.AllowAnyHeader();
});
});
Then:
app.UseCors("AllowFrontend");
Avoid this in production unless you have a specific reason:
.AllowAnyOrigin()
Instead, explicitly specify the origins that should be allowed.
15. Synchronous vs Asynchronous Communication
Until now, we've mostly talked about synchronous communication.
For example:
Frontend
|
Gateway
|
Order Service
|
Response
The client waits for the response.
But sometimes services don't need to wait for each other.
For example:
Order Created
|
+----> Send Email
|
+----> Update Analytics
|
+----> Notify Warehouse
Instead of calling every service synchronously, we can publish an event:
Order Service
|
v
Message Broker
|
+----> Notification Service
|
+----> Analytics Service
|
+----> Warehouse Service
This is asynchronous messaging.
16. RabbitMQ
RabbitMQ is commonly used for message-based communication.
Conceptually:
Producer
|
v
RabbitMQ
|
v
Consumer
A simple producer might look like:
var factory = new ConnectionFactory
{
HostName = "localhost"
};
using var connection = factory.CreateConnection();
using var channel = connection.CreateModel();
channel.QueueDeclare(
"orders",
durable: true);
var body =
Encoding.UTF8.GetBytes(message);
channel.BasicPublish(
"",
"orders",
null,
body);
RabbitMQ is a good option when your application needs traditional queues, consumers, acknowledgements, and routing patterns.
17. Kafka
Kafka is another popular messaging platform.
The mental model is slightly different.
Instead of thinking primarily about a queue, think about an event stream/topic.
Producer
|
v
Kafka Topic
|
+---- Consumer A
|
+---- Consumer B
|
+---- Consumer C
A .NET producer can use:
dotnet add package Confluent.Kafka
Then:
var config = new ProducerConfig
{
BootstrapServers = "localhost:9092"
};
using var producer =
new ProducerBuilder<Null, string>(config)
.Build();
await producer.ProduceAsync(
topic,
new Message<Null, string>
{
Value = message
});
Kafka is particularly useful when you need high-throughput event streaming and multiple consumers need to process the same events independently.
18. Azure Service Bus
If you're running primarily in Azure, Azure Service Bus is another option.
For example:
dotnet add package Azure.Messaging.ServiceBus
A simplified sender:
await using var client =
new ServiceBusClient(connectionString);
var sender =
client.CreateSender("orders");
var message =
new ServiceBusMessage("Order created");
await sender.SendMessageAsync(message);
The important point is that RabbitMQ, Kafka, and Azure Service Bus solve related but different messaging requirements.
Don't choose a technology simply because it is popular.
Choose based on:
- Message ordering
- Throughput
- Delivery guarantees
- Replay requirements
- Consumer patterns
- Cloud environment
- Operational complexity
19. Logging
Once your system has multiple services, debugging becomes difficult.
Imagine:
Frontend
|
Gateway
|
Order Service
|
Payment Service
|
Database
A single user request can travel through several applications.
Centralized logging becomes extremely important.
A simple Serilog configuration:
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.WriteTo.File(
"logs/gateway-.txt",
rollingInterval: RollingInterval.Day)
.CreateLogger();
builder.Host.UseSerilog();
In production, you may send logs to centralized systems such as:
Application
|
v
Centralized Logging
|
+--> Search
+--> Alerts
+--> Dashboards
The most useful information to log includes:
- Request ID / correlation ID
- HTTP method
- URL
- Status code
- Duration
- Exception
- Service name
Avoid logging passwords, tokens, and other sensitive information.
20. Metrics
Logs tell you what happened.
Metrics help you understand how your system is behaving.
Useful gateway metrics include:
Requests per second
Average response time
95th percentile latency
5xx errors
4xx errors
Rate-limit violations
Circuit-breaker failures
CPU
Memory
For example, Prometheus can expose:
/metrics
and monitoring tools can collect those metrics.
A custom counter might look like:
var requestCounter =
Metrics.CreateCounter(
"gateway_requests_total",
"Total gateway requests",
new CounterConfiguration
{
LabelNames = new[]
{
"method",
"path"
}
});
Together:
Logs -> detailed events
Metrics -> system health/performance
Traces -> request journey across services
For larger microservice environments, distributed tracing becomes especially valuable.
21. Deployment
Once the gateway works locally, the next question is:
Where should we run it?
One common option is Docker.
A simplified Dockerfile:
FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app
COPY . .
ENTRYPOINT ["dotnet", "MyGateway.dll"]
Build:
docker build -t my-gateway .
Run:
docker run -d -p 8080:80 my-gateway
Now your gateway runs inside a container.
22. Scaling the Gateway
Suppose one gateway instance receives:
10,000 requests/minute
Eventually you may need multiple instances:
Load Balancer
/ | \
v v v
Gateway Gateway Gateway
This is called horizontal scaling.
Instead of making one server bigger:
1 huge server
we run:
3 smaller servers
or:
10 instances
depending on traffic.
For this to work well, the gateway should preferably be stateless.
Avoid storing important user/session state only in memory.
For shared caching, use something like Redis.
23. Kubernetes Scaling
Kubernetes can automatically scale gateway instances.
For example:
spec:
replicas: 3
starts three gateway pods.
You can also configure a Horizontal Pod Autoscaler:
minReplicas: 2
maxReplicas: 10
For example:
Low traffic
|
v
2 Gateway Pods
High traffic
|
v
5 Gateway Pods
Very high traffic
|
v
10 Gateway Pods
The infrastructure automatically adjusts the number of instances based on configured metrics.
24. Putting Everything Together
A production-style architecture might look like this:
Internet
|
v
+----------------+
| Load Balancer |
+----------------+
|
v
+--------------------+
| API Gateway |
|--------------------|
| Routing |
| JWT |
| Rate Limiting |
| Caching |
| CORS |
| IP Filtering |
| Circuit Breaker |
| Health Checks |
| Logging |
| Metrics |
+--------------------+
/ | \
/ | \
v v v
User Service Product Order
Service Service
|
v
Message Broker
/ | \
v v v
Notification Analytics
Service
This is the bigger picture.
25. What Should You Implement First?
If you are a beginner, don't try to implement everything on day one.
A good progression is:
Step 1 - Basic Routing
Gateway
|
+--> Product
+--> Order
Step 2 - Environment Configuration
Move service URLs and configuration out of hard-coded values.
Step 3 - Authentication
Add JWT validation.
Step 4 - CORS
Allow only your legitimate frontend applications.
Step 5 - Rate Limiting
Protect APIs from excessive traffic.
Step 6 - Caching
Cache suitable GET requests.
Step 7 - Health Checks
Allow infrastructure to determine whether services are healthy.
Step 8 - Circuit Breakers
Prevent cascading failures.
Step 9 - Logging and Metrics
Understand what your system is doing.
Step 10 - Messaging
Introduce RabbitMQ, Kafka, or Azure Service Bus when asynchronous communication is actually needed.
Step 11 - Deployment
Containerize the gateway.
Step 12 - Scaling
Use multiple instances and, when appropriate, Kubernetes or cloud autoscaling.
26. Final Mental Model
If you remember only one thing from this article, remember this:
API Gateway
|
+-----------------+----------------+
| | |
Security Performance Reliability
| | |
JWT Rate Limiting Circuit Breaker
CORS Cache Health Check
IP Filter Retry/Timeout
|
+---------------------------------------+
|
v
Microservices
The API Gateway is not simply a proxy.
It becomes the controlled entry point into your microservices environment.
It can simplify the frontend, centralize common concerns, protect backend services, and provide a consistent place for routing, security, resilience, and observability.
But there is an important lesson:
Don't turn the gateway into another giant application.
Keep business logic inside the appropriate microservices.
The gateway should primarily handle concerns that belong at the edge of your system.
Start simple:
Routing
↓
Security
↓
Rate Limiting
↓
Caching
↓
Resilience
↓
Observability
↓
Messaging
↓
Deployment & Scaling
Once you understand this progression, concepts like Ocelot, Redis, RabbitMQ, Kafka, Azure Service Bus, Docker, Kubernetes, JWT, circuit breakers, and distributed tracing start fitting into the same overall picture.
Your gateway is the front door of your APIs. Keep that door secure, resilient, observable, and simple.
Enjoyed this article? Share it with your network!