Service Discovery, Config & Gateway

Project: A Discoverable Microservices Setup

18 min Lesson 10 of 12

Project: A Discoverable Microservices Setup

The previous nine lessons have introduced each layer of the Spring Cloud stack individually: Eureka for registry, Spring Cloud Config for centralised properties, and Spring Cloud Gateway for the single entry point. This final lesson ties them together into a working, deployable system. By the end you will have three runnable services — a Config Server, a Eureka registry, and an API Gateway — plus one business service that registers with Eureka, fetches its configuration from the Config Server, and is reachable through the Gateway. Everything is wired the way a production team would actually wire it.

System Topology

Before writing any code, picture the four processes and the order in which they start:

  1. Config Server — starts first; reads properties from a Git repository (or a local classpath folder for the project). All other services are its clients.
  2. Eureka Server — starts second; fetches its own configuration from the Config Server, then opens its registry for business. Other services register here.
  3. API Gateway — starts third; registers with Eureka and fetches routing rules from the Config Server. It resolves service addresses via Eureka at request time.
  4. Order Service (sample business service) — starts last; registers with Eureka and fetches its application.properties from the Config Server. The Gateway routes inbound traffic to it by its Eureka service ID, never by hardcoded host/port.
Why this startup order matters: If the Config Server is not ready when Eureka tries to bootstrap, Eureka will fail to start. If Eureka is not ready when the Gateway starts, the Gateway will have an empty registry. In production you enforce this with container orchestration health checks (Kubernetes readinessProbe) or with Spring Cloud's retry-on-connect bootstrap. For a local run, simply start the four services in the order above with a few seconds between each.

1. Config Server

Create a Spring Boot project with the spring-cloud-config-server dependency, then annotate the main class:

@SpringBootApplication @EnableConfigServer public class ConfigServerApplication { public static void main(String[] args) { SpringApplication.run(ConfigServerApplication.class, args); } }

src/main/resources/application.yml — point it at a local classpath folder so the project works without a remote Git server:

server: port: 8888 spring: application: name: config-server cloud: config: server: native: search-locations: classpath:/config-repo profiles: active: native

Create the folder src/main/resources/config-repo/ and add one file per downstream service. The naming convention is {application-name}.yml.

eureka-server.yml

server: port: 8761 eureka: instance: hostname: localhost client: register-with-eureka: false fetch-registry: false

api-gateway.yml

server: port: 8080 spring: cloud: gateway: discovery: locator: enabled: true lower-case-service-id: true

order-service.yml

server: port: 8081 orders: max-per-customer: 10
One config-repo, many services: The Config Server resolves the correct file by matching the spring.application.name of the connecting client. You never hardcode which service gets which file — the client identifies itself and the server does the matching.

2. Eureka Server

Create a second project with spring-cloud-starter-netflix-eureka-server, annotate the main class with @EnableEurekaServer, and set its bootstrap to pull from the Config Server:

# src/main/resources/application.yml spring: application: name: eureka-server config: import: "configserver:http://localhost:8888"

That single spring.config.import line is the entire bootstrap. Spring Cloud Config Client (on the classpath) intercepts startup, contacts http://localhost:8888/eureka-server/default, and merges the remote properties before any bean is created. The eureka-server.yml file you placed in the config repo now controls port 8761 and the standalone registry flags.

3. API Gateway

Create a project with spring-cloud-starter-gateway, spring-cloud-starter-netflix-eureka-client, and spring-cloud-starter-config. Main class:

@SpringBootApplication public class ApiGatewayApplication { public static void main(String[] args) { SpringApplication.run(ApiGatewayApplication.class, args); } }

Bootstrap properties:

# src/main/resources/application.yml spring: application: name: api-gateway config: import: "configserver:http://localhost:8888" eureka: client: service-url: defaultZone: http://localhost:8761/eureka/

With discovery.locator.enabled: true (set in the remote api-gateway.yml), the Gateway automatically creates a route for every service registered in Eureka. A request to http://localhost:8080/order-service/api/orders is resolved by stripping the service-ID prefix and forwarding to whichever instance of order-service Eureka knows about — no manually declared routes required.

Load balancing is automatic: Spring Cloud Gateway uses ReactorLoadBalancerExchangeFilterFunction backed by Spring Cloud LoadBalancer. When Eureka returns multiple instances of order-service, the Gateway picks one using a round-robin strategy by default. No Ribbon, no extra code.

4. Order Service

A minimal REST service that reads a config value and registers with Eureka:

@SpringBootApplication public class OrderServiceApplication { public static void main(String[] args) { SpringApplication.run(OrderServiceApplication.class, args); } }
@RestController @RequestMapping("/api/orders") @RefreshScope public class OrderController { @Value("${orders.max-per-customer:5}") private int maxPerCustomer; @GetMapping("/config") public Map<String, Object> config() { return Map.of("maxPerCustomer", maxPerCustomer); } @GetMapping public List<String> listOrders() { return List.of("ORD-001", "ORD-002"); } }
# src/main/resources/application.yml spring: application: name: order-service config: import: "configserver:http://localhost:8888" eureka: client: service-url: defaultZone: http://localhost:8761/eureka/ instance: prefer-ip-address: true

prefer-ip-address: true tells Eureka to register the service's IP instead of its hostname. This avoids DNS resolution issues inside containers and is the standard setting for containerised deployments.

End-to-End Smoke Test

Start all four processes in order, then verify each layer:

  1. Config Server health: GET http://localhost:8888/order-service/default — should return the merged properties JSON.
  2. Eureka dashboard: open http://localhost:8761 — you should see API-GATEWAY and ORDER-SERVICE listed as UP.
  3. Direct call: GET http://localhost:8081/api/orders — bypasses the Gateway, confirms the service works.
  4. Gateway call: GET http://localhost:8080/order-service/api/orders — routed via Eureka, confirms discovery and routing work together.
  5. Config read-through: GET http://localhost:8080/order-service/api/orders/config — should return {"maxPerCustomer": 10}, proving the value came from the Config Server.

Security Considerations

A production setup needs several hardening steps that this project skeleton intentionally omits for clarity:

  • Secure the Config Server actuator and config endpoints. Add Spring Security to the Config Server and require HTTP Basic or OAuth2 credentials. Without this, anyone who can reach port 8888 can read every service's secrets.
  • Encrypt sensitive values. The Config Server supports symmetric (AES) and asymmetric (RSA) encryption via {cipher} prefixes. Store the cipher text in Git; the server decrypts on delivery.
  • Restrict the Eureka management port. The /eureka/apps API is unauthenticated by default. In production, place Eureka behind a private network or add Spring Security with a service-account password.
  • Never expose the Gateway management endpoints publicly. The /actuator/gateway/routes endpoint reveals your entire routing table. Bind the management port to a non-public interface.
The discovery locator is powerful but broad. With discovery.locator.enabled: true, every service in Eureka becomes reachable through the Gateway. If you register an internal admin service with Eureka, it is now publicly accessible unless you add explicit route predicates or a security filter to block it. In production, prefer explicit route definitions over the auto-discovery locator, or whitelist the service IDs that should be exposed.

Distributed-Systems Trade-offs

This architecture solves real problems but introduces new ones. Know the trade-offs before committing:

  • Config Server as a single point of failure. If it goes down at startup, dependent services cannot boot. Mitigate with a Config Server cluster (Spring Cloud Config Server supports multiple Git replicas) or by caching a local copy of properties with spring.cloud.config.fail-fast: false and a local application.yml fallback.
  • Eureka eventual consistency. Eureka uses a heartbeat model, not a consensus protocol. A freshly deregistered service can remain in the registry for up to 90 seconds (3 missed heartbeats). Your clients must tolerate a small number of calls to dead instances — which is exactly why circuit breakers (Resilience4j, covered in the next tutorial) pair naturally with service discovery.
  • Gateway as a bottleneck. All public traffic flows through one process. Run at least two Gateway instances behind a hardware or cloud load balancer, and size them for the peak fan-out of your most expensive routes.

Summary

You now have a complete, working skeleton: a Config Server that owns all properties, a Eureka registry that owns all addresses, and a Gateway that resolves both at request time. The Order Service never knows where its peers live — it asks Eureka. It never owns its own configuration — it asks the Config Server. Adding a new service to this system means writing the business logic and a spring.application.name; the infrastructure takes care of the rest. That is the payoff of the patterns you have studied in this tutorial.