LearnAI ToolsCareerPractice BuildsPlayContact
Spring BootIntermediate~2 hours

Production-Ready Service

Add health checks, logging, and profiles to a REST API for deployment.

ActuatorProfilesLogging

Overview

Every project so far in this course has focused on making an API work correctly. This one focuses on the questions that only matter once that API is actually running somewhere: is it up? What environment is it configured for right now — a laptop or a production server? And when something goes wrong at 2 a.m., is there a log line that actually explains what happened? Spring Boot Actuator, `@Profile`, and SLF4J are the three tools this project builds around to answer those questions.

By the end of this tutorial you will have a small inventory-tracking service exposing `/actuator/health` and `/actuator/info`, separate `application-dev.properties` and `application-prod.properties` files activated by `spring.profiles.active`, a `NotificationService` with two `@Profile`-gated implementations that swap automatically depending on which profile is active, and structured SLF4J logging in a service class that never string-concatenates a log message by hand.

What You'll Build
  • The `spring-boot-starter-actuator` dependency, exposing `/actuator/health` and `/actuator/info`.
  • Separate `application-dev.properties` and `application-prod.properties` files, selected by `spring.profiles.active`.
  • A `NotificationService` interface with `@Profile("dev")` and `@Profile("prod")` implementations.
  • An `InventoryService` using SLF4J's `Logger` with parameterized, placeholder-based log messages.
  • Production datasource credentials read from environment variables, never hardcoded in a properties file.

Prerequisites

  • This course's Task Manager REST API project — `@RestController`, `@Service`, and Spring Data JPA basics.
  • Dependency injection basics — how Spring chooses which bean to inject when more than one implementation of an interface exists.
  • Basic logging concepts — log levels (`DEBUG`, `INFO`, `WARN`, `ERROR`) and why they exist.
  • Environment variables — how a shell or a deployment platform sets a process-level variable a running application can read.
  • A Spring Boot 3.x project with the Spring Web and Spring Data JPA starters (the Actuator starter is added in Step 1).

Project Structure

This project keeps the same layered layout used throughout the course: `src/main/java/com/programinds/inventory/service/` holds `InventoryService` and both `NotificationService` implementations; `controller/` holds a small `InventoryController`; and `src/main/resources/` holds three properties files instead of one — `application.properties` for settings shared by every environment, plus `application-dev.properties` and `application-prod.properties` for everything that legitimately differs between them.

The production-readiness features in this project are deliberately orthogonal to each other: Actuator (Step 1) reports whether the app is healthy, profiles (Steps 2 and 3) change which configuration and beans are active without touching a single line of Java, and logging (Step 4) explains what the running application actually did. A real deployment checklist touches all three independently — this tutorial builds each one in isolation so it is clear which lever controls which behavior.

Step 1: Add the Actuator Starter and Expose Health/Info

Adding `spring-boot-starter-actuator` to the build immediately gets you a `/actuator/health` endpoint reporting `UP` or `DOWN` — Actuator auto-configures itself by inspecting what else is on the classpath, the same auto-configuration philosophy the whole framework is built on. By default, Actuator only exposes `/actuator/health` over HTTP; `management.endpoints.web.exposure.include` in `application.properties` is what opens up `/actuator/info` alongside it, and the `info.*` properties are what actually populate its response.

pom.xml (dependency to add)
<!-- Auto-configures /actuator/health, /actuator/info, and more, based purely
on this dependency being present on the classpath — no Java code required. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
application.properties
spring.application.name=inventory-service
# Actuator exposes ONLY /actuator/health over HTTP by default. This line opens
# up /actuator/info as well; a real production setup would review this list
# carefully rather than exposing every actuator endpoint publicly.
management.endpoints.web.exposure.include=health,info
# Populates the JSON body returned by GET /actuator/info.
info.app.name=Inventory Service
info.app.description=Production-ready Spring Boot REST API

Step 2: Configure Profile-Specific Properties Files

Spring Boot loads `application.properties` first, then layers `application-{profile}.properties` on top for whichever profile is active, with the profile-specific file winning on any key both define. `application-dev.properties` points at the same H2 in-memory database used throughout this course and turns on `DEBUG` logging; `application-prod.properties` points at real datasource credentials — read from environment variables, which Step 5 explains — and dials logging back down to `INFO` so production logs stay readable instead of drowning in development-level detail.

application-dev.properties
# Active when spring.profiles.active=dev. No install required, resets on restart.
spring.datasource.url=jdbc:h2:mem:inventorydb
spring.datasource.driver-class-name=org.h2.Driver
spring.jpa.hibernate.ddl-auto=update
# DEBUG locally so every SQL statement and framework decision is visible while developing.
logging.level.com.programinds.inventory=DEBUG
application-prod.properties
# Active when spring.profiles.active=prod. Values come from environment
# variables (Step 5) — never a literal connection string or password here.
spring.datasource.url=${DATABASE_URL}
spring.datasource.username=${DATABASE_USERNAME}
spring.datasource.password=${DATABASE_PASSWORD}
spring.jpa.hibernate.ddl-auto=validate
# INFO in production: DEBUG-level detail would flood production logs and can
# leak more information than a production environment should expose.
logging.level.com.programinds.inventory=INFO
Settingdevprod
DatasourceH2 in-memory, no setup requiredReal database, credentials from environment variables
`ddl-auto``update` — schema evolves automatically`validate` — schema must already match; never auto-altered
Log level`DEBUG``INFO`
Notification bean (Step 3)`DevNotificationService``ProdNotificationService`

Which file is layered on top of `application.properties` is controlled by `spring.profiles.active`, set either as a property, a command-line argument (`--spring.profiles.active=prod`), or — the way Step 5 recommends for production — the `SPRING_PROFILES_ACTIVE` environment variable, which Spring Boot automatically maps onto the same setting by its relaxed binding rules.

Step 3: Define a Profile-Specific Bean with @Profile

`@Profile` on a `@Service` class controls whether Spring registers that bean at all for the currently active profile — `DevNotificationService` exists in the application context only when `dev` is active, and `ProdNotificationService` only when `prod` is active. `InventoryService` in the next step depends on the `NotificationService` interface, never on either concrete class, so it never has to know or care which one is actually running underneath it.

service/NotificationService.java
package com.programinds.inventory.service;
// A plain interface — InventoryService (Step 4) depends on this, never on a
// concrete implementation, which is what lets the two @Profile-gated classes
// below be swapped by configuration alone, with zero changes to InventoryService.
public interface NotificationService {
void notifyLowStock(String productName, int remaining);
}
service/DevNotificationService.java
package com.programinds.inventory.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
// @Profile("dev") means Spring only registers this bean when
// spring.profiles.active=dev. It never touches a real notification provider —
// exactly what a local development run should do.
@Service
@Profile("dev")
public class DevNotificationService implements NotificationService {
private static final Logger log = LoggerFactory.getLogger(DevNotificationService.class);
@Override
public void notifyLowStock(String productName, int remaining) {
log.info("[DEV] Low stock alert: {} has {} left", productName, remaining);
}
}
service/ProdNotificationService.java
package com.programinds.inventory.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
@Service
@Profile("prod") // Only registered when spring.profiles.active=prod
public class ProdNotificationService implements NotificationService {
private static final Logger log = LoggerFactory.getLogger(ProdNotificationService.class);
@Override
public void notifyLowStock(String productName, int remaining) {
// A real implementation would call an email/SMS/Slack provider here,
// authenticated with credentials read from environment variables
// (Step 5) — never hardcoded in this class.
log.warn("Low stock alert: {} has {} left — notifying operations team", productName, remaining);
}
}
Exactly One Must Be Active

If neither `dev` nor `prod` is active, Spring finds zero beans implementing `NotificationService` and `InventoryService` in Step 4 fails to start with a `NoSuchBeanDefinitionException`. If both profiles were ever active simultaneously, Spring would find two candidates and fail with an ambiguous-bean error instead. Exactly one of the two must be active at any given time — `application.properties` sets a `spring.profiles.active=dev` default in the Complete Code section below specifically so a plain `./mvnw spring-boot:run` still starts successfully.

Step 4: Add SLF4J Structured Logging to a Service

`LoggerFactory.getLogger(InventoryService.class)` creates one `Logger` per class, which is the SLF4J convention — the class name shows up in every log line so you always know exactly which component logged it. The `{}` placeholders in `log.info("Sale recorded for {}, {} units remaining", productName, stockRemaining)` are deliberate: SLF4J only builds the final string if that log level is actually enabled, so a `log.debug(...)` call with placeholders costs almost nothing when `DEBUG` is disabled in production, unlike string concatenation with `+`, which always runs regardless of whether the result is ever used.

service/InventoryService.java
package com.programinds.inventory.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@Service
public class InventoryService {
// One Logger per class is the SLF4J convention — every log line this class
// produces is automatically tagged with "InventoryService" so its origin is unambiguous.
private static final Logger log = LoggerFactory.getLogger(InventoryService.class);
private static final int LOW_STOCK_THRESHOLD = 5;
private final NotificationService notificationService;
// Spring injects whichever @Profile-gated implementation from Step 3 is
// currently active. InventoryService never checks which one it got.
public InventoryService(NotificationService notificationService) {
this.notificationService = notificationService;
}
public void recordSale(String productName, int stockRemaining) {
// {} placeholders: SLF4J only formats this string if INFO is enabled for
// this logger, unlike "productName + " is at " + stockRemaining", which
// always builds the string whether or not it ends up logged anywhere.
log.info("Sale recorded for {}, {} units remaining", productName, stockRemaining);
if (stockRemaining <= LOW_STOCK_THRESHOLD) {
log.warn("{} is below the low-stock threshold of {}", productName, LOW_STOCK_THRESHOLD);
notificationService.notifyLowStock(productName, stockRemaining);
}
}
}

Step 5: Externalize Secrets With Environment Variables

`application-prod.properties` back in Step 2 never contains a literal database password — `${DATABASE_URL}`, `${DATABASE_USERNAME}`, and `${DATABASE_PASSWORD}` are placeholders Spring Boot resolves from environment variables (or a config server, or a secrets manager wired in the same way) at startup. This matters for a reason beyond convention: a properties file typically lives in version control, and a hardcoded production password committed to Git is exposed to everyone with repository access, forever, even after the file is later edited.

Starting the app in each profile
# Local development: reads application-dev.properties, no secrets required
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
# Production: reads application-prod.properties, and its ${...} placeholders
# are resolved from these environment variables, set by the deployment platform
# (a process manager, container orchestrator, or PaaS config panel) — never
# typed into a properties file that could end up committed to source control.
export SPRING_PROFILES_ACTIVE=prod
export DATABASE_URL=jdbc:postgresql://prod-db.internal:5432/inventory
export DATABASE_USERNAME=inventory_service
export DATABASE_PASSWORD=********
java -jar inventory-service.jar
Never Commit Real Secrets

Even a placeholder like `${DATABASE_PASSWORD}` in a properties file is safe to commit — it is not a value, just a reference to one. The actual password should exist only in the deployment environment's secret store (environment variables, a vault service, or a platform's encrypted config), never typed directly into any file tracked by Git.

Complete Code

Here is the application entry point and a small controller demonstrating `InventoryService`, plus the base `application.properties` with a development default so the app starts successfully even without `SPRING_PROFILES_ACTIVE` set explicitly.

application.properties (with a dev default)
spring.application.name=inventory-service
management.endpoints.web.exposure.include=health,info
info.app.name=Inventory Service
info.app.description=Production-ready Spring Boot REST API
# Defaults to dev so a plain "./mvnw spring-boot:run" still starts successfully
# with exactly one NotificationService bean available. A real deployment always
# overrides this via the SPRING_PROFILES_ACTIVE environment variable instead.
spring.profiles.active=dev
controller/InventoryController.java
package com.programinds.inventory.controller;
import com.programinds.inventory.service.InventoryService;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/inventory")
public class InventoryController {
private final InventoryService inventoryService;
public InventoryController(InventoryService inventoryService) {
this.inventoryService = inventoryService;
}
@PostMapping("/sale")
public String recordSale(@RequestParam String productName, @RequestParam int stockRemaining) {
inventoryService.recordSale(productName, stockRemaining); // Logs (Step 4) and may trigger a low-stock notification (Step 3)
return "Sale recorded for " + productName;
}
}
InventoryApplication.java
package com.programinds.inventory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class InventoryApplication {
public static void main(String[] args) {
SpringApplication.run(InventoryApplication.class, args);
}
}

Sample Run

Sample Run (dev profile)

Click Run to see what this code prints.

Extend This Project

  • Add a custom `HealthIndicator` bean that reports `DOWN` if a critical downstream dependency (e.g. a payment provider) is unreachable, so `/actuator/health` reflects more than just "the app process is running."
  • Add `management.endpoint.health.show-details=when-authorized` and secure the Actuator endpoints with Spring Security so `/actuator/health` details aren't exposed to unauthenticated callers in production.
  • Introduce a third `application-staging.properties` profile that mirrors production configuration but points at a staging database, and test that `NotificationService` selection still resolves correctly.
  • Replace plain-text log output with structured JSON logging (e.g. via Logback's `LogstashEncoder`) so production logs are easily ingested by a log aggregation platform.
  • Add a `/actuator/metrics` exposure and instrument `InventoryService.recordSale()` with a custom Micrometer counter tracking low-stock events over time.

Summary

You took a working REST API and made it deployable: Actuator answers "is it up?" without any custom code, `@Profile` and profile-specific properties files swap both configuration and entire beans based on which environment is active, SLF4J's parameterized logging gives you a real record of what the running service actually did, and environment-variable-based configuration keeps production secrets out of source control entirely. These four pieces — health checks, profiles, logging, and externalized secrets — are the baseline every real Spring Boot service needs before it is safe to deploy.