Tech Blog

Resetting the Dev DB to the Same State Every Time With DevPortSeedInitializer — Spring Boot's CommandLineRunner in Practice

by Tech Writer
Spring Boot Java PostgreSQL CommandLineRunner Test Data dev profile

What You’ll Learn

  • A pattern for inserting fixed test data at startup using Spring Boot’s CommandLineRunner + @Profile("dev")
  • An upsert (findById + save) approach that doesn’t double-insert on restart
  • A distribution algorithm that lays out multiple ports in a ring around stations
  • A design that intentionally mixes in anomaly data (full ports, broken scooters) for verification

Target Audience

  • Anyone tired of doing truncate + insert every time on a dev DB
  • People who want fixed “full port” / “broken scooter” anomaly data on hand for E2E checks
  • Anyone wanting a seed mechanism in Spring Boot stdlib only (lighter than Flyway / Liquibase)

Environment

ItemVersion
Java21
Spring Boot3.4.5
PostgreSQL17.2
Spring Data JPA3.x

Series article (in active development) — This article is part of Building Street-Level E-Scooter Sharing From Scratch to Understand It — Design, Implementation, and Operations Series. The project is ongoing; new articles and design notes are added continuously. The series motivation, technology stack, screen-ID glossary, and the full article index are collected at the link above.

Introduction

In the PoC phase with PostgreSQL, you keep hitting:

  • Recreating the DB wipes the master data and screens go empty
  • Sharing the dev environment with a teammate via “I’ll DM you the SQL” workflow
  • Crafting “full port” / “under maintenance” anomalies by hand every time

We fixed this by injecting the data in Java code at app startup.

Home map right after launch — 4 stations × 20 ports already placed

Green pins are “ports with available scooters,” gray ones are “zero available or full ports.” That variance itself is intentionally produced by the seed code — the theme of this article.


Overall Structure

@Component
@Profile("dev")
@RequiredArgsConstructor
public class DevPortSeedInitializer implements CommandLineRunner {

    private final PortRepository portRepository;
    private final ScooterRepository scooterRepository;

    @Override
    public void run(String... args) {
        seedStation("TOKYO", "Tokyo Station area", 35.681236, 139.767125);
        seedStation("IKEBUKURO", "Ikebukuro Station area", 35.728926, 139.710380);
        seedStation("OTSUKA", "Otsuka Station area", 35.731401, 139.728662);
        seedStation("SHINJUKU", "Shinjuku Station area", 35.690921, 139.700258);
    }
    // ...
}

Points:

  • @Profile("dev") means the bean isn’t created in prod. No accidental seed in production
  • CommandLineRunner.run is called once at the very end of Spring Boot startup
  • One method per station consolidates geographic distribution and count management in one place

Upsert Logic for Restart Safety

A plain save causes a duplicate-key violation on every restart. Instead, findById to check, then build new or update existing:

private void seedStation(String code, String stationLabel, double centerLat, double centerLng) {
    for (int i = 1; i <= 20; i++) {
        String index = String.format("%02d", i);
        String portId = "PORT-" + code + "-" + index;
        String portName = stationLabel + " Port " + index;

        LatLng latLng = distributedPoint(centerLat, centerLng, i);
        Port port = portRepository.findById(portId)
            .orElseGet(() -> Port.builder().portId(portId).build());
        port.setName(portName);
        port.setLatitude(BigDecimal.valueOf(latLng.lat));
        port.setLongitude(BigDecimal.valueOf(latLng.lng));
        port.setTotalCapacity(12);
        portRepository.save(port);

        // Same upsert for scooters
        upsertScooter(port, "SC-" + code + "-" + index + "-A", "AVAILABLE");
        upsertScooter(port, "SC-" + code + "-" + index + "-B", "AVAILABLE");
        upsertScooter(port, "SC-" + code + "-" + index + "-C", "MAINTENANCE");
    }
}

private void upsertScooter(Port port, String scooterId, String status) {
    Scooter scooter = scooterRepository.findById(scooterId)
        .orElseGet(() -> Scooter.builder().scooterId(scooterId).build());
    scooter.setStatus(status);
    scooter.setBatteryLevel("AVAILABLE".equals(status) ? 85 : 45);
    scooter.setCurrentLatitude(port.getLatitude());
    scooter.setCurrentLongitude(port.getLongitude());
    scooter.setCurrentPort(port);
    scooterRepository.save(scooter);
}

Effects:

  • First start: all rows INSERTed
  • Subsequent starts: existing rows UPDATED (lat/lng or status follow changes)
  • States users mutated during runtime (e.g. IN_USE) snap back to AVAILABLE/MAINTENANCE on next restart

The last point gives a “dev startup always returns to a known initial state” effect. Even if state breaks during verification, a restart resets it — a real comfort.


Ring-Shaped Distribution Around Stations

Putting all 20 ports at the same coordinate causes overlap. We spread them out by distance:

private LatLng distributedPoint(double centerLat, double centerLng, int index) {
    int ring = index <= 8 ? 0 : (index <= 14 ? 1 : 2);
    double radiusMeters = ring == 0 ? 180 : (ring == 1 ? 320 : 520);
    double angleDeg = (index * 31) % 360;

    double latDelta = (radiusMeters / 111_320.0) * Math.cos(Math.toRadians(angleDeg));
    double lngScale = Math.max(Math.cos(Math.toRadians(centerLat)), 0.1);
    double lngDelta = (radiusMeters / (111_320.0 * lngScale)) * Math.sin(Math.toRadians(angleDeg));
    return new LatLng(centerLat + latDelta, centerLng + lngDelta);
}

Algorithm:

  • index 1〜8 → inner ring (180m radius)
  • index 9〜14 → middle ring (320m radius)
  • index 15〜20 → outer ring (520m radius)
  • angle: index * 31 % 360 (31 is coprime to 360, so successive indices don’t cluster)

111_320.0 is meters per degree of latitude near the equator. Longitude varies with latitude, so we correct with cos(centerLat).

The result is ports arranged in concentric circles around each station, all visible on the map.


Intentionally Mixing In Anomalies

During verification you sometimes want a “full port” or “all-maintenance port.” Seed them in deliberately:

for (int i = 1; i <= 20; i++) {
    // i % 7 == 0 (i=7 and i=14 per station) becomes a "full port" — totalCapacity equals scooter count,
    // making available_empty_slots = 0
    boolean isFullPort = i % 7 == 0;
    String nameSuffix = isFullPort ? " (full)" : "";
    int totalCapacity = isFullPort ? 3 : 12;

    Port port = portRepository.findById(portId)
        .orElseGet(() -> Port.builder().portId(portId).build());
    port.setName(portName + nameSuffix);
    port.setTotalCapacity(totalCapacity);
    portRepository.save(port);

    int availableCount = i % 5 == 0 ? 0 : 2;
    upsertScooter(port, "SC-" + code + "-" + index + "-A",
        availableCount >= 1 ? "AVAILABLE" : "MAINTENANCE");
    upsertScooter(port, "SC-" + code + "-" + index + "-B",
        availableCount >= 2 ? "AVAILABLE" : "MAINTENANCE");
    upsertScooter(port, "SC-" + code + "-" + index + "-C", "MAINTENANCE");
}

Properties encoded into i:

  • i % 7 == 0 (i=7, 14) → full ports. 2 per station × 4 stations = 8 total
  • i % 5 == 0 (i=5, 10, 15, 20) → ports with zero AVAILABLE. 4 per station × 4 = 16 total
  • Otherwise → 2 AVAILABLE + 1 MAINTENANCE

Open the map and you’ll see green pins (normal) and gray pins (full or zero-available) mixed. Client-side “pin coloring” / “button disabling” logic gets verified from the moment of startup.

PORT-OTSUKA-14 (full) — the result of seed condition i=14 assigning totalCapacity=3

The “(full)” suffix in the port name is also produced by the seed code as isFullPort ? " (full)" : "", so at a glance you can tell “this is exactly what the seed intended.” A fixed, reproducible verification fixture for checking that “button disabling shows up as expected.”


Why Not Flyway / Liquibase

Migration tools (Flyway / Liquibase) can seed too. But for dev test data, Java has reasons:

  • Hundreds of lines of loops and coordinate math in SQL hurt readability
  • Putting the coordinate generation logic in SQL makes it unreusable in tests
  • @Profile("dev") can completely confine it to dev (Flyway is often expected to run in prod too)

The natural division of labor becomes: schema management with Flyway / Liquibase, test data with CommandLineRunner.


Lessons

  • A dev seed is simple and strong as @Profile("dev") + CommandLineRunner
  • Upsert logic (findById → save) gives restart resilience — runtime state damage isn’t scary anymore
  • Stations / coordinates / counts / anomalies are declarative in code, so reviewable
  • Modulo branches like i % N == 0 deliberately seed anomalies, making routine verification easier

In the early PoC phase, “running and verifying design” is the priority. “Every time you open the DB you see the same view” stabilizes the dev experience more than you’d expect.

Feel free to send a message

Please send a message if you have any technical questions, feedback, or inquiries.