Platform engineering and internal developer platform: concepts and application

1. Overview

As the number of services grows, each team must handle their own CI/CD, infrastructure, logging, security, and documentation. They spend much time on repetitive tasks instead of writing features. Platform engineering solves this by consolidating these tasks into an internal developer platform (IDP) for developer self-service.

After reading this article, you’ll understand the concept and why it matters. You’ll also be able to build a small “golden path” using a Spring Boot starter, service catalog, and a simple policy check. At the end is when you shouldn’t invest in a platform.

2. What is platform engineering?

Platform engineering is when a dedicated team (platform team) builds and operates a set of tools, processes, and internal services. Product teams (stream-aligned teams) use this to build, deploy, and operate services without needing deep knowledge of every infrastructure layer underneath.

2.1. Internal developer platform

The IDP is the product that the platform team creates. It’s usually not a single tool. It’s an integration layer that brings together existing things: Git, CI, Kubernetes, cloud, secret manager, observability. Developers interact with this layer through a portal, CLI, templates, or just a configuration file in their repo.

2.2. Three core ideas

  • Golden path (paved road): the recommended and supported way to do something common, such as “create a new REST service”. Following this path is fast and safe. Going a different route is allowed, but the team bears responsibility.
  • Self-service: developers create services, databases, or test environments themselves without opening a ticket and waiting for someone else.
  • Platform as a product: internal developers are customers. The platform team must interview them, measure usage, and prioritize the backlog like a real product team.

2.3. How is it different from DevOps?

DevOps is culture: the team that writes code is also responsible for running it (“you build it, you run it”). Platform engineering doesn’t replace DevOps. It’s a way to implement DevOps at scale. Instead of making every team become a Kubernetes, Terraform, and IAM expert, the platform team packages that knowledge into easy-to-use abstractions.

3. Why is this topic getting attention now?

There are several practical reasons, not tied to any specific tool:

  1. Increasing cognitive load: a modern service might need a Dockerfile, Kubernetes manifest, pipeline, dashboard, alerts, security policies, and secret configuration. Each has many options.
  2. Every team does it their own way: without a common standard, each service logs in a different format and names metrics differently. When there’s an incident, on-call engineers struggle to figure out which team owns which service.
  3. Security and compliance requirements: scanning dependencies, signing artifacts, controlling access. These are easy to apply consistently if built into the golden path.
  4. Slow onboarding: new engineers need to know “where do I create a service, how do I deploy, where do I see logs”. A good platform answers all these questions in one place.

4. Common IDP components

Component Role
Service catalog List of services, owners, documentation, dependencies
Software template Create new projects according to standards with just a few parameters
Shared CI/CD Pre-built pipeline for build, test, scan, deploy
Self-service infrastructure Create databases, queues, buckets via declarative configuration
Default observability Logging, metrics, traces available by default when a service starts
Guardrail / policy Automatic checks for mandatory rules

You don’t need all of these right away. The sections below show three small pieces: a Spring Boot starter for the golden path, a catalog descriptor, and a policy check.

5. Golden path with Spring Boot starter

The cheapest way to standardize Java services is to release an internal starter. Any service that adds this starter will automatically have standard metadata: name, owning team, criticality. The metadata is exposed via the actuator.

5.1. Dependency

The internal starter needs the following dependencies:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-autoconfigure</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-actuator</artifactId>
        <optional>true</optional>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Actuator is marked optional. Services that don’t use actuator can still use the starter.

5.2. Properties and descriptor

package dev.nonla.platform;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties("platform.service")
public record PlatformServiceProperties(String name, String owner, String tier) {

    public PlatformServiceProperties {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("platform.service.name is required");
        }
        if (owner == null || owner.isBlank()) {
            throw new IllegalArgumentException("platform.service.owner is required");
        }
        if (tier == null || tier.isBlank()) {
            tier = "standard";
        }
    }
}
package dev.nonla.platform;

public record ServiceDescriptor(String name, String owner, String tier) {
}

The record’s compact constructor does two things:

  • Requires declaring name and owner. If either is missing, the application won’t start. This is a guardrail: no orphaned services.
  • Assigns a default value of standard to tier if not specified.

5.3. Auto-configuration

package dev.nonla.platform;

import java.util.Map;
import org.springframework.boot.actuate.info.InfoContributor;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@AutoConfiguration
@EnableConfigurationProperties(PlatformServiceProperties.class)
public class PlatformServiceAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    ServiceDescriptor serviceDescriptor(PlatformServiceProperties props) {
        return new ServiceDescriptor(props.name(), props.owner(), props.tier());
    }

    @Configuration(proxyBeanMethods = false)
    @ConditionalOnClass(InfoContributor.class)
    static class ActuatorConfiguration {

        @Bean
        InfoContributor platformInfoContributor(ServiceDescriptor descriptor) {
            return builder -> builder.withDetail("platform", Map.of(
                    "name", descriptor.name(),
                    "owner", descriptor.owner(),
                    "tier", descriptor.tier()));
        }
    }
}

Explaining each part:

  • @ConditionalOnMissingBean lets services define their own ServiceDescriptor if needed. This is an “escape hatch” from the golden path when truly necessary.
  • The nested class ActuatorConfiguration is only loaded if InfoContributor is on the classpath. This way, services not using actuator won’t encounter a ClassNotFoundException.

For Spring Boot 3 to find this auto-configuration, register it in src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports:

dev.nonla.platform.PlatformServiceAutoConfiguration

On the service side, for example order-service, you only need to add the starter and a few lines of configuration:

platform.service.name=order-service
platform.service.owner=team-checkout
platform.service.tier=critical
management.endpoints.web.exposure.include=health,info
management.info.env.enabled=false

Calling GET /actuator/info returns:

{"platform":{"name":"order-service","owner":"team-checkout","tier":"critical"}}

From this, monitoring tools or the catalog can scrape this endpoint to learn who owns which service.

5.4. Testing starter behavior

The platform team must test the starter thoroughly. A bug here affects every service using it. ApplicationContextRunner helps test auto-configuration quickly without starting a full web server:

package dev.nonla.platform;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.info.InfoContributor;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;

class PlatformServiceAutoConfigurationTest {

    private final ApplicationContextRunner runner = new ApplicationContextRunner()
            .withConfiguration(AutoConfigurations.of(PlatformServiceAutoConfiguration.class));

    @Test
    void givenNameAndOwner_whenStarting_thenDescriptorUsesDefaultTier() {
        runner.withPropertyValues(
                        "platform.service.name=order-service",
                        "platform.service.owner=team-checkout")
                .run(ctx -> {
                    assertThat(ctx).hasSingleBean(ServiceDescriptor.class)
                            .hasSingleBean(InfoContributor.class);
                    assertThat(ctx.getBean(ServiceDescriptor.class).tier())
                            .isEqualTo("standard");
                });
    }

    @Test
    void givenMissingOwner_whenStarting_thenContextFails() {
        runner.withPropertyValues("platform.service.name=order-service")
                .run(ctx -> {
                    assertThat(ctx).hasFailed();
                    assertThat(ctx.getStartupFailure())
                            .rootCause()
                            .hasMessageContaining("platform.service.owner");
                });
    }
}

The first test confirms default values and InfoContributor registration. The second test confirms the guardrail: without owner, the context fails to start, and the error message clearly indicates which property is missing.

6. Service catalog and guardrail

6.1. Catalog descriptor in repo

Most IDPs store service metadata right in the repo, next to the code. Below is a descriptor in the style commonly seen in open-source source portals:

apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
  name: order-service
  description: Handles orders and payments
  tags:
    - java
    - spring-boot
spec:
  type: service
  lifecycle: production
  owner: team-checkout
  system: checkout
  dependsOn:
    - resource:orders-db
  providesApis:
    - order-api

Storing this file in Git has two benefits. Metadata is reviewed alongside code, and it doesn’t drift from reality like a forgotten wiki page. The portal only needs to read this file to display the owner, dependencies, and APIs of a service.

6.2. Simple policy check

Guardrails don’t necessarily need a complex policy engine. To start, a bit of code running in CI is enough to block bad configurations:

package dev.nonla.platform.policy;

import java.util.ArrayList;
import java.util.List;
import java.util.Set;

public class ServicePolicy {

    private static final Set<String> ALLOWED_TIERS = Set.of("critical", "standard", "experimental");

    public record ServiceSpec(String name, String owner, String tier, int replicas) {
    }

    public List<String> violations(ServiceSpec spec) {
        List<String> errors = new ArrayList<>();
        if (!spec.name().matches("[a-z][a-z0-9-]{2,40}")) {
            errors.add("name must be kebab-case, 3-41 chars");
        }
        if (!spec.owner().startsWith("team-")) {
            errors.add("owner must be a team, not a person");
        }
        if (!ALLOWED_TIERS.contains(spec.tier())) {
            errors.add("unknown tier: " + spec.tier());
        }
        if ("critical".equals(spec.tier()) && spec.replicas() < 2) {
            errors.add("critical services need at least 2 replicas");
        }
        return errors;
    }
}

Given input new ServiceSpec("Order_Service", "alice", "critical", 1), the function returns:

[name must be kebab-case, 3-41 chars, owner must be a team, not a person, critical services need at least 2 replicas]

The important point is that error messages must clearly state what needs to be fixed. Good guardrails guide developers toward correction, not just block them.

7. Real-world application: where to start?

  1. Interview developers first. Ask where they spend the most time: creating new services, waiting for environments, or debugging pipelines. Prioritize the most painful problems, not the most interesting tools.
  2. Start with the “thinnest viable platform”. Sometimes just a template repo, a shared pipeline, and a documentation page suffice. The portal can come later.
  3. Pick one golden path only. For example, “Spring Boot REST service deployed to Kubernetes”. Master this path first, then expand.
  4. Measure. Track adoption across teams, time from repo creation to first deployment, and regular developer feedback. If no one voluntarily uses it, that’s a sign the product isn’t right.
  5. Keep an escape hatch. The golden path should be the easiest route, not the only mandatory one. As in section 5.3, @ConditionalOnMissingBean is a small example of this principle.
  6. Clear versioning and deprecation. Starters, templates, and shared pipelines are all APIs. Breaking changes must be announced with a migration path.

8. Common mistakes and when not to use it

Common mistakes:

  • Build the portal before you have content. A pretty portal with an empty catalog and outdated templates will be quickly forgotten.
  • Rename the ops team to “platform team”. If developers still have to open tickets and wait, that’s not self-service.
  • Over-abstract. Completely hiding Kubernetes leaves developers unable to debug when things break. A platform should reduce cognitive load, not remove visibility into lower layers.
  • Enforce through rules instead of value. If people use it only because you mandate it, your platform hasn’t solved the real problem.
  • Do-everything starters. A starter with too many dependencies and implicit configuration causes version conflicts and makes upgrades painful.

Note: Platform engineering has significant fixed costs. You need people to build it, operate it, and support it.

When not to invest in a dedicated IDP:

  • Small companies with just a few teams and services. A good README and shared pipeline usually suffice.
  • Your product is still finding product-market fit and architecture changes often. Standardizing too early locks in choices you’re about to change.
  • Cloud-managed services already meet your needs. Building your own layer just adds maintenance burden.
  • No one is willing to be the product owner for the platform. Without someone listening to developers, it becomes a toolbox nobody wants to use.

9. Conclusion

Platform engineering isn’t a new tool. It’s a way of organizing work: a team treats the internal developer experience as a product and packages infrastructure knowledge into golden paths and self-service. You’ve seen three small pieces: a Spring Boot starter that standardizes metadata with tests, a catalog descriptor in your repo, and a policy check in CI.

Start small, measure real impact, and only expand when developers actually use it. If your organization is small, a good template and a shared pipeline might deliver most of the benefits without needing a full platform.

platform-engineeringdevopsspring-bootdeveloper-experiencetech-trends