Hello!

© 2025 Kishan Kumar. All rights reserved.

Build your own API Gateway from Scratch in Java

An API Gateway is a server that acts as an entry point for all your API requests from client to target application or services.

Mar 16, 2025

Hero

Photo by Resource Database on Unsplash

While working in software engineering or preparing for a job interview you often would have come across a service called API Gateway. You might also be knowing at a very high level that it is the very first service that your request lands on and from there it is routed correctly to your microservices.

To give you a more formal introduction:

An API Gateway is a server that acts as an entry point for all your API requests from client to target application or services.

Let’s understand why we even need an API Gateway, that way we will get a better understanding of their importance in the micro service architecture. But first let’s also understand what is a microservice architecture in the first place.

Think of microservice architecture as a practice where we split our application say, instagram in two or more service. Each service is assigned a unique role for e.g. one service can take care of all the user related queries (such as user management—sign up / log in) and the other service can take care of handling the posts (fetching and saving it in the DB).

Let’s take a concrete idea and build upon it. Say we want to build a simple news feed service, that consists of the following microservices:

  • Post Service: a service to deal with CRUD operations related to a Post
  • User Service: a service that generates feed for a user

Take the following diagram into consideration:

High level overview of gateway

High level overview of gateway

Let’s break it down, we have our client that wants to talk to the Post Service. In order to talk to the Post Service, it needs to know the name of the service and the domain of the API Gateway. The domain of our gateway is, say, api.example.com.

The client first calls the DNS server to resolve api.example.com and gets some IP address. This part has been omitted for simplicity. Once the client knows the IP address, it has to follow a strict convention followed by the org.

In our case we have followed the following REST notation.

api.example.com/service/:service/:path

Where :service represents the name of the microservice uniquely identified inside the vast pool of services, and :path represents the API endpoint exposed by that service.

Let’s take an example to understand, say we want to get all the post for a user whose username is kishan. First our post service should expose an API to get all the post. Say it has exposed an API.

GET /users/:userId/all → this returns all the post associated with user having userId. Since we want to fetch all the post belonging to user kishan. We need to hit, GET /users/kishan/allto the post service. But since Post service can’t be hit directly as it is present inside a private network and not exposed to the internet, we’ll have to come via the API gateway.

Here is an example of what the request might look like.

api.example.com/services/post-service/users/kishan/all

Let’s break it down, when calling the above request, it will first go to the API gateway since it has the public domain of gateway. After reaching there, the gateway will analyse the request, it will break the endpoint into which service does this request was meant for, in our case it is meant for post-service. After that it will analyse to which endpoint of the post service do we need to reach to in our case it is /users/kishan/all.

The API gateway will check for post-service in its service discovery and figure out the host and port for it and initiate an HTTP request to that service. The service in turns return the response to the gateway which is then forwarded to the client.

Note, in order for client to reach to the post service, it need to know two key important part:

  • The name of the service (:service)
  • The endpoint exposed by that service (:path)

Here the API GW acts as a router between the client and the end application.

Let’s start our journey of building our API gateway

Since we have understood the basic terms, we will keep on explaining the process as and where required.

For this project, we will be using Vert.x as our toolkit to build our API gateway. I have written a brief introduction on Vert.x that you can follow.

Introduction to Vert.x

Vert.x is called a "Polyglot" toolkit because it supports multiple programming languages and paradigms

To summarise it, think of it as a set of libraries that helps the developer build reactive applications that run on JVM. It uses an event-loop, a thread that runs in an infinite loop, checking for and dispatching events on the appropriate handlers. Think of an event as a network IO, file IO, timers, messages, etc., whereas handlers are the pieces that are registered by the application to handle those specific events. For e.g., if the request succeeds (an event) execute a set of instructions.

All this code goes in what is called Verticles in Vert.x, they are the basic units of deployment and execution. We’ll understand more about it as we explain it using an example in later section of this article.

Setting up our project

Let’s start with setting up our project, we are going to use IntelliJ IDEA as our IDE.

  1. Start with opening the IDE and create a new project and name it gateway.
  2. Choose Maven as the build system.
  3. Once done, click on Create.
IntelliJ New Project

IntelliJ New Project

This will create a new maven project for you. Since we are going to be using vertx, we’d need to import few of its dependencies in our pom.xml file.

You can fork the complete repository from my github profile: https://github.com/confusedconsciousness/gateway

We’ll be needing the following dependencies:

  • vertx-core
  • vertx-web
  • vertx-web-client
  • lombok
  • sl4fj-simple and sl4fj-api (for logging)
  • guava (for basic collections)

Here is what your pom.xml file will look like:

1<?xml version="1.0" encoding="UTF-8"?>
2<project xmlns="http://maven.apache.org/POM/4.0.0"
3         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
5    <modelVersion>4.0.0</modelVersion>
6
7    <groupId>org.example</groupId>
8    <artifactId>gateway</artifactId>
9    <version>1.0-SNAPSHOT</version>
10
11    <properties>
12        <maven.compiler.source>23</maven.compiler.source>
13        <maven.compiler.target>23</maven.compiler.target>
14        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
15        <vertx.version>4.5.9</vertx.version>
16        <lombok.version>1.18.30</lombok.version>
17    </properties>
18
19    <dependencies>
20        <dependency>
21            <groupId>io.vertx</groupId>
22            <artifactId>vertx-core</artifactId>
23            <version>${vertx.version}</version>
24        </dependency>
25        <dependency>
26            <groupId>io.vertx</groupId>
27            <artifactId>vertx-web</artifactId>
28            <version>${vertx.version}</version>
29        </dependency>
30        <dependency>
31            <groupId>io.vertx</groupId>
32            <artifactId>vertx-web-client</artifactId>
33            <version>${vertx.version}</version>
34        </dependency>   
35        <dependency>
36            <groupId>org.projectlombok</groupId>
37            <artifactId>lombok</artifactId>
38            <version>${lombok.version}</version>
39            <scope>provided</scope>
40        </dependency>
41        <dependency>
42            <groupId>org.slf4j</groupId>
43            <artifactId>slf4j-api</artifactId>
44            <version>2.0.16</version>
45        </dependency>
46        <dependency>
47            <groupId>org.slf4j</groupId>
48            <artifactId>slf4j-simple</artifactId>
49            <version>2.0.16</version>
50        </dependency>
51        <!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
52        <dependency>
53            <groupId>com.google.guava</groupId>
54            <artifactId>guava</artifactId>
55            <version>33.4.0-jre</version>
56        </dependency>
57    </dependencies>
58
59</project>

Now, let’s start with setting up our basic gateway. We’ll create a class and name it Server.java as this will be our entry point.

I have tried to make things as verbose as possible and have left comment at all places so that it is easy for a beginner to understand what a piece of code snippet is doing. Below is the code for our Server class.

You can see that this class has our main() which is the entry point for executing a program. This method is basically initiating our Server class and invoking the start() method.

In this class, we are initialising the vertx object, that will be used for deploying verticles and routers.

Vertx vertx = Vertx.vertx();

Since the gateway, needs to know what all services are present, we have created what is called a ServerConfig class. This class basically stores:

  • VertxConfig
  • ServiceConfig
1package org.example;
2
3import io.vertx.core.Vertx;
4import lombok.NoArgsConstructor;
5import lombok.extern.slf4j.Slf4j;
6import org.example.model.config.ServerConfig;
7import org.example.model.config.VertxConfig;
8import org.example.model.config.MicroserviceConfig;
9import org.example.model.endpoint.StaticEndpoint;
10import org.example.verticle.GatewayVerticle;
11import org.example.microservice.Photogram;
12import org.example.microservice.UserService;
13
14import java.util.Map;
15
16@Slf4j
17@NoArgsConstructor
18public class Server {
19
20    public static void main(String[] args) {
21        new Server().start();
22    }
23
24    public void start() {
25        log.info("Starting server");
26        // starting point of our server, this method will initialise all the components
27        Vertx vertx = Vertx.vertx();
28        // for simplicity let's add the config manually, ideally this should come from some config store
29        // we are onboarding only two services named photogram (another internal service running on localhost:8921),
30        // and user-service that is running on 8945
31        ServerConfig config = ServerConfig.builder()
32                .vertxConfig(VertxConfig.builder().defaultPort(8080).build())
33                .serviceConfigs(Map.of("photogram", MicroserviceConfig.builder()
34                        .endpoint(StaticEndpoint.builder()
35                                .host("localhost")
36                                .port(8921)
37                                .build())
38                        .build(), "user-service", MicroserviceConfig.builder()
39                        .endpoint(StaticEndpoint.builder()
40                                .host("localhost")
41                                .port(8945)
42                                .build())
43                        .build()))
44                .build();
45
46        // deploy our units
47        deployGatewayVerticle(vertx, config);
48        deployMicroservices(vertx, config);
49    }
50
51    private void deployGatewayVerticle(Vertx vertx, ServerConfig config) {
52        vertx.deployVerticle(new GatewayVerticle(config), res -> {
53            if (res.succeeded()) {
54                log.info("Gateway Verticle deployed");
55            } else {
56                log.error("Gateway Verticle deploy failed", res.cause());
57            }
58        });
59    }
60
61    private void deployMicroservices(Vertx vertx, ServerConfig config) {
62        // deploy the microservices as well in the same project for simplicity
63        // our photogram service is responsible for handling posts
64        vertx.deployVerticle(new Photogram(config), res -> {
65            if (res.succeeded()) {
66                log.info("Photogram Service deployed");
67            } else {
68                log.error("Photogram Service deploy failed", res.cause());
69            }
70        });
71        // our user service is responsible for managing users
72        vertx.deployVerticle(new UserService(config), res -> {
73            if (res.succeeded()) {
74                log.info("User Service deployed");
75            } else {
76                log.error("User Service deploy failed", res.cause());
77            }
78        });
79    }
80}
81

The service config contains the details such as the name of the service onboarded onto gateway and how to discover it. Whether we need to discover it via simple host and port or via some service discovery that makes use of zookeeper.

Microservices

In our case, we are only dealing with two services:

  • Photogram: a mock service that exposes two APIs /post and /view. Consider it as a service where users can create a post and share it with their friends, similar to Instagram.
  • UserService: a mock service that manages users, such as create, login, delete. It only exposes one API /create.

Notice both of the above services are running locally, one is running at localhost:8921 and the other is running at localhost:8945 .

You can change the ports if you get any error while bringing up the gateway; it might be possible that those ports mentioned above are used by some other application. Or, you can also create more services and try it on your own.

After defining our services, it is now time to deploy our gateway and our microservices as well. For that, we have created two methods:

  • deployGatewayVerticle()
  • deployMicroservices()

From the name, it is pretty evident what they do. If you look closely, they all are very similar. Both of these methods use the deployVerticle() method present in the vertx object. The method takes a Verticle object and a handler that is invoked if the verticle is deployed successfully or not.

Verticles

Let’s understand what a verticle is before going forward in detail.

Verticles are chunks of code that get deployed and run by Vert.x. Here, we are deploying GatewayVerticle that has all the logic corresponding to how our gateway will handle the request and MicroserviceVerticle (Photogram, and UserService) that has their own logic.

Let’s first dive deep into the GatewayVerticle. You can see that our verticle extends AbstractVerticle. This is done so that we don’t have to write our own Verticle from scratch. All we need to do is override the start() method and write our own logic in it. When the Verticle is deployed, its start method is called. You can see that in the start method, we are initializing a Router.

Routers

A Router is used to handle the HTTP Request; it is part of the vert.x-web module, which we added in our dependency. One can route the request based on paths, for example, /api/users, /services/:id.

We can also route based on HTTP methods like GET, POST, PUT, etc.

In our case, we have defined a simple path-based route with a path parameter and regex, and called it DEFAULT_MOUNT_PATH. Any request that has /services/:service/anything/example/etc will be served by this very router.

Let’s take an example: since our gateway is running on localhost:8080, if we hit localhost:8080/services/photogram/post, our router will intercept it. If we hit localhost:8080/something/photogram/post, our router will fail to intercept it and will throw Resource not found.

Post that, we have defined handlers; in our case, we have defined three handlers:

  • validator
  • forwarder
  • endRouter

All these handlers are called one by one in serial order. You can create just one handler and put all the logic in it, but here, I have created three of them to segregate the logic.

The validator handler validates if the request that is coming is for a valid service. Since we have only onboarded two services: photogram and user-service, if we get a request for, say, post-service, we will fail the request and won’t let it proceed to the next handlers.

The forwarder handler basically creates an HTTP client and sends the request to the respective upstream. If a request came for the photogram service on the gateway, our gateway will find the host and port of that service, append the path to it, create a requestUri, and send the request.

The endRouter handler is just a way to know that we have completed the request. We can skip it as well.

Defining a router is okay, but we need to create an HTTP server that will actually listen for those requests, and that is where we will create our gateway server and bind it to the router.

Vert.x provides a method called createHttpServer(); we then bind our router to it using requestHandler() that takes a router object and listens to port 80 or whatever we want.

I will leave the code of validator, forwarder, and the endRouter handlers for the reader to understand. The code is heavily commented for ease of readability.

GatewayVerticle.java
1package org.example.verticle;
2
3import com.google.common.base.Strings;
4import io.vertx.core.AbstractVerticle;
5import io.vertx.core.AsyncResult;
6import io.vertx.core.Promise;
7import io.vertx.core.buffer.Buffer;
8import io.vertx.core.http.HttpMethod;
9import io.vertx.core.http.HttpServer;
10import io.vertx.core.http.HttpServerOptions;
11import io.vertx.core.json.JsonObject;
12import io.vertx.ext.web.Router;
13import io.vertx.ext.web.RoutingContext;
14import io.vertx.ext.web.client.HttpRequest;
15import io.vertx.ext.web.client.HttpResponse;
16import io.vertx.ext.web.client.WebClient;
17import lombok.extern.slf4j.Slf4j;
18import org.example.model.config.ServerConfig;
19import org.example.model.endpoint.EndpointType;
20import org.example.model.config.MicroserviceConfig;
21import org.example.model.endpoint.StaticEndpoint;
22
23import java.util.Objects;
24import java.util.UUID;
25
26@Slf4j
27public class GatewayVerticle extends AbstractVerticle {
28    private final ServerConfig serverConfig;
29    public static String DEFAULT_MOUNT_PATH = "/services/:service/*";
30
31    private WebClient webClient;
32
33    public GatewayVerticle(ServerConfig serverConfig) {
34        this.serverConfig = serverConfig;
35    }
36
37    @Override
38    public void start(Promise<Void> startPromise) throws Exception {
39        // create a router
40        Router router = Router.router(vertx);
41        this.webClient = WebClient.create(vertx);
42        // mount this router for the requests that begins with /services (because that is convention we are going to follow)
43        // if somebody comes at some other path say /random/* we will fail it
44
45        // when someone will come to /services/name-of-service/anything,
46        // our router will come into the picture, and it will go through all these handlers one by one
47        // handlers are meant for a lot of thing, we have a validator handler that will check whether the request is correct
48        // forward handler that forwards the request to the upstream, and 
49        // end handler simply just marks the end of the request
50        router.route(DEFAULT_MOUNT_PATH)
51                .handler(this::validator)
52                .handler(this::forwarder)
53                .handler(this::endRouter);
54
55        // the above was just a router, but we want to create an HTTP server
56        // that will listen and when the request will land, our router will get triggered
57        HttpServerOptions defaultHttpServerOptions = new HttpServerOptions();
58        HttpServer server = vertx.createHttpServer(defaultHttpServerOptions);
59
60        server.requestHandler(router)
61                .listen(serverConfig.getVertxConfig().getDefaultPort());
62    }
63
64
65    public void validator(RoutingContext routingContext) {
66        // let's understand few things here
67        // /services/:service/:path
68        String service = routingContext.pathParam("service");
69        String path = routingContext.pathParam("*");
70
71        String requestId = routingContext.get("requestId");
72        if (Strings.isNullOrEmpty(requestId)) {
73            requestId = UUID.randomUUID().toString();
74            routingContext.put("requestId", requestId);
75        }
76
77        // let's log the request for which service this request was targeted for and for what endpoint
78        log.info("Received request on service: {}, for path: {}, with requestId: {}", service, path, requestId);
79        // check if the service is even onboarded or visible to gateway?
80        if (serverConfig.getServiceConfigs().containsKey(service)) {
81            // we are just printing the info here
82            // you can forward the request to the upstream by fetching the endpoint type and all
83            // putting this information so that the upcoming handler have this information
84            routingContext.put("service", service);
85            routingContext.put("path", path);
86            // move to the next handler
87            routingContext.next();
88        } else {
89            // fail the request by saying the service is not present
90            routingContext.response()
91                    .end(new JsonObject()
92                            .put("status", 404)
93                            .put("error", String.format("Service '%s' not found", service))
94                            .encode());
95        }
96
97    }
98
99    public void forwarder(RoutingContext routingContext) {
100        // this handler forwards the request to the upstream
101        String requestUri = buildRequestUri(routingContext);
102
103        log.info("Upstream Request URI: {}", requestUri);
104        HttpRequest<Buffer> httpRequest = getHttpRequest(routingContext, requestUri);
105        httpRequest.send(clientResponse -> handleResponse(routingContext, clientResponse));
106    }
107
108    private void handleResponse(RoutingContext routingContext, AsyncResult<HttpResponse<Buffer>> clientResponse) {
109        // this method will be called when we receive a response from the client
110        if (clientResponse.succeeded()) {
111            HttpResponse<Buffer> httpResponse = clientResponse.result();
112            routingContext.response().setStatusCode(200);
113            routingContext.response().end(httpResponse.bodyAsString());
114        } else {
115            routingContext.response().setStatusCode(500);
116            log.error("Error while handling response", clientResponse.cause());
117        }
118        routingContext.next();
119    }
120
121    public void endRouter(RoutingContext routingContext) {
122        log.info("Ending Request");
123    }
124
125
126    public MicroserviceConfig getServiceConfig(RoutingContext routingContext) {
127        String service = routingContext.pathParam("service");
128        if (Strings.isNullOrEmpty(service)) {
129            throw new IllegalArgumentException("Missing required parameter 'service'");
130        }
131        return serverConfig.getServiceConfigs().get(service);
132    }
133
134    public String buildRequestUri(RoutingContext routingContext) {
135
136        MicroserviceConfig microserviceConfig = getServiceConfig(routingContext);
137        // find the host and port for the service where the request needs to be forwarded
138        // we are only covering the static endpoint part
139        // in order to build up the complete request, we need to first get the host and port of the microservice and
140        // then append the path to it
141        // host:port/path
142        if (Objects.requireNonNull(microserviceConfig.getEndpoint().getType()) == EndpointType.STATIC) {
143            StaticEndpoint endpoint = (StaticEndpoint) microserviceConfig.getEndpoint();
144            // http scheme + host + port + path
145            return String.format("%s://%s:%s/%s", "http", endpoint.getHost(), endpoint.getPort(), routingContext.get("path"));
146        }
147        return "";
148    }
149
150    public HttpRequest<Buffer> getHttpRequest(RoutingContext routingContext, String requestUri) {
151        HttpMethod method = routingContext.request().method();
152        switch (String.valueOf(method)) {
153            case "GET" -> {
154                return webClient.getAbs(requestUri);
155            }
156            case "POST" -> {
157                return webClient.postAbs(requestUri);
158            }
159            default -> throw new IllegalStateException("Unexpected value: " + method);
160        }
161    }
162
163}

Now let’s move on to the microservices. We have created two of them: Photogram and UserService. Both of these resemble the GatewayVerticle; the difference is that they are simply creating routes, logging the requests, and returning responses.

In Photogram, we have created two routes: /post and /view. Once done, we then spin up an HTTP server and register these routes to it.

Photogram.java
1package org.example.microservice;
2
3import io.vertx.core.AbstractVerticle;
4import io.vertx.core.Promise;
5import io.vertx.core.json.JsonArray;
6import io.vertx.core.json.JsonObject;
7import io.vertx.ext.web.Router;
8import lombok.extern.slf4j.Slf4j;
9import org.example.model.config.ServerConfig;
10import org.example.model.config.MicroserviceConfig;
11import org.example.model.endpoint.StaticEndpoint;
12
13import java.util.List;
14
15@Slf4j
16public class Photogram extends AbstractVerticle {
17    // this verticle will act like a microservice (postgram)
18    private final ServerConfig serverConfig;
19
20    public Photogram(ServerConfig serverConfig) {
21        this.serverConfig = serverConfig;
22    }
23
24    @Override
25    public void start(Promise<Void> startPromise) throws Exception {
26        super.start(startPromise);
27        MicroserviceConfig postgramConfig = serverConfig.getServiceConfigs().get("photogram");
28        Router router = Router.router(vertx);
29
30        // our photogram service will expose two apis
31        // 1. one to post the post and other to view the post
32        // 2. please note we are not getting into the nitty-gritty of the whole service we are just mocking them
33        router.route("/post").handler(context -> {
34            log.info("Creating a new Post on Photogram");
35            context.json(new JsonObject().put("message", "Successfully Created a Post"));
36        });
37
38        router.route("/view").handler(context -> {
39            log.info("Received a view request on Photogram");
40            context.json(new JsonObject().put("posts", new JsonArray(List.of("P21, P45, P68"))));
41        });
42
43        vertx.createHttpServer()
44                .requestHandler(router)
45                .listen(((StaticEndpoint) postgramConfig.getEndpoint()).getPort());
46
47    }
48}
49
UserService.java
1package org.example.microservice;
2
3import io.vertx.core.AbstractVerticle;
4import io.vertx.core.Promise;
5import io.vertx.core.json.JsonObject;
6import io.vertx.ext.web.Router;
7import org.example.model.config.ServerConfig;
8import org.example.model.config.MicroserviceConfig;
9import org.example.model.endpoint.StaticEndpoint;
10
11public class UserService extends AbstractVerticle {
12    private final ServerConfig serverConfig;
13
14    public UserService(ServerConfig serverConfig) {
15        this.serverConfig = serverConfig;
16    }
17
18    @Override
19    public void start(Promise<Void> startPromise) throws Exception {
20        super.start(startPromise);
21
22        MicroserviceConfig userServiceConfig = serverConfig.getServiceConfigs().get("user-service");
23
24        Router router = Router.router(vertx);
25        // we'll be exposing yet another service called user service that will manage our users such as creating, signing up etc
26        router.route("/create").handler(routingContext -> {
27            routingContext.json(new JsonObject().put("message", "Successfully Registered an User"));
28        });
29
30        vertx.createHttpServer()
31                .requestHandler(router)
32                .listen(((StaticEndpoint) userServiceConfig.getEndpoint()).getPort());
33
34    }
35}
36

Spin it up

Let’s now spin up our gateway. We’ll see the following logs:

Startup logs

Startup logs

You can see in the logs that we are getting Photogram Service deployed and User Service deployed.

Now, let’s go to the web browser and hit localhost:8080 and see what we get. We got Resource Not Found, but why?

Because we haven’t registered any router for "/" path. Let’s go to the Photogram service and hit its /post API and see what it returns.

In order to hit that API, we need to visit localhost:8080/services/photogram/post , and we get the following message on our browser.

Response from Postgram Service

Response from Postgram Service

What if we try to hit a service that isn’t registered or onboarded onto the gateway? Let’s try to hit post-service with the /post API.

The endpoint we need is localhost:8080/services/post-service/post .

You can see that we got a 404 - Service not found message from the gateway.

Error from gateway

Error from gateway

Further Considerations

Now, since this was a very basic example of an API gateway, it lacks a few things that I will let the user explore, such as:

  • Authentication/Authorization
  • Rate Limiting
  • Checksum Validation
  • User Pinning

You can visit my GitHub repo to view the complete source code: https://github.com/confusedconsciousness/gateway .

I hope this article helped you gain insight into what a gateway does and a little bit about the Vert.x toolkit as well.

.   .   .

The 0xkishan Newsletter

Subscribe to the newsletter to learn more about the decentralized web, AI and technology.

Comments on this article

Please be respectful!

© 2025 Kishan Kumar. All rights reserved.