LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
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-autoupdate — schema evolves automaticallyvalidate — schema must already match; never auto-altered
Log levelDEBUGINFO
Notification bean (Step 3)DevNotificationServiceProdNotificationService

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.