Skip to content

Lab 3: SQL & ORM

Goals

Your logic-service currently keeps everything it knows inside the running application. This lab turns one part of that knowledge, the Points of Interest your squad runs into during a mission, into data your service manages deliberately: recorded by your Mission Logic, reached through a DAO layer of your own design, stored in a SQL database through an ORM, and finally exposed through an API of its own.

Alongside this, improve your Mission Logic with combat and orbital call-ins, add a coverage gate, and prepare a clean release image. These are required parts of the combined Lab 3–5 checkpoint: start them here, then carry the service into staging in Lab 4 and release it for the real game in Lab 5. The full list of deliverables for this lab is in the Checklist at the bottom of this page.

Introduction

Lab 1 and Lab 2 left you with a logic-service that keeps everything in memory: a plain Java Map, filled in and read by your own code, holding the missions you are playing and, by now, a growing set of decision-logic rules. If you followed the suggested approach from Lab 2, your logic probably already tracks something like a list of known deposit locations in memory, so it can send a squad member back towards a deposit it isn't currently looking at. That has worked fine so far, because only your own running application ever needed to see that state.

This lab turns that idea into something more deliberate: a Point of Interest (POI), a thing worth remembering during a single mission (a resource deposit, a hostile organism, ...), tied to the mission it was spotted in. Unlike a strategic decision (which only matters once, between missions), POIs are genuinely useful during a single mission run: they're the thing your own logic wants to remember turn after turn, and, once made available through an API, the thing a teammate or a dashboard could ask about too. A SQL-backed DAO gives you a path to durable storage later. The in-memory H2 database used here does not survive a service restart; this lab teaches the boundary, not restart recovery.

A note on AI

Using an AI coding assistant while working on this lab? See Using AI for our stance on this course, and a template AGENTS.md you can drop into your repository to keep it in teaching-assistant mode rather than solving the lab for you.

What's a Point of Interest?

A POI can be modeled generically as in the following example shape:

Field Meaning
missionId The mission it was spotted in. Reads are always scoped to one mission.
type DEPOSIT, LARGE_DEPOSIT or HOSTILE_ORGANISM.
location The tile (x, y).
turnSpotted The turn this POI was last seen.
resourceType For deposits only (otherwise empty): which resource it holds.

A POI is identified by missionId, type and location. Saving the same sighting twice must not create a second row, and that rule is the same for every type. Use entityId as part of the identity only if you decide you need it: the DepositFoundEvent from scan-completed has none, so a deposit POI cannot rely on one.

Optional: richer state per POI

The table is a minimum, not a ceiling. You may store finer-grained state, such as a hostile organism's HP, attack range or strength. This comes at a cost: every extra field is another column to map, another value both DAO implementations must round-trip identically, and another thing to keep fresh as the world changes. Nested model-lib records cannot be mapped as an entity directly, so you would flatten them into plain fields (or an @Embeddable). Only do this if a decision in your logic actually uses it, and keep the identity rule above unchanged.

At minimum, track these as POIs:

  • Normal deposits your squad has seen in locationsInView (you may already be tracking these in memory, per the Lab 2 suggestion).
  • Large deposits. These are recorded in two ways: from a DepositFoundEvent on scan-completed, immediately and before the deposit is in view, and when your squad sees one. See the manual for how they differ from normal deposits.
  • Living hostile organisms your squad has seen in locationsInView.

Some questions are deliberately left to you:

  • A large deposit that comes into view must update the POI you already hold, not add a second deposit at the same location. How do you recognise it as the same one?
  • Depleted deposits and defeated hostile organisms disappear from the map, so their POIs become stale, but nothing tells you so: you would have to notice that a tile in view no longer holds the entity (see locationsInView for what the view includes). That detection requires some more effort, detecting and deleting stale POIs is optional for now. Only attempt this when other requirements are fulfilled and tested.
  • When the mission ends is not optional. The backend ends a mission with DELETE /missions/{missionId}. Either remove all POIs for that mission (and any working set you keep), or mark them as stale so a later clean-up can remove them. Either way, they must no longer show up as current POIs for that mission.
  • Your POI storage is yours to extend. If your logic needs to remember other things that should survive a restart later (a target per squad member, say), add them. The only requirement is that the POI contract above keeps working.

How to work through this lab

Start with team planning. Then divide the work; the sections below are guides to deliverables, not a sequence that all three teammates must finish one after another. Use the test-first cycle within each stream.

Agree on POI model, DAO contract, ownershipLogic: POIs, combat, call-ins + testsData: memory DAO, H2/JPA + testsAPI + Postman; coverage + image hygieneIntegrate and review

One teammate can work on logic, another on the DAO and JPA, and another on the API, coverage, and image hygiene. This is an example split, not a rule: review each other's work and integrate early. Peer programming is also a very valid option especially for the DAO and JPA work, which is new to everyone. The API can begin with a fixture while POI tracking is being written; combat and call-ins can progress independently of persistence. Coverage configuration can start early even though the final threshold depends on everyone's tests.

In the three-hour session, aim to agree on the shared contract, assign work items, and get one POI flowing from a sighting through the DAO while the other streams start. Finish and integrate the deliverables across the shared Lab 3–5 checkpoint, but be mindful that those labs introduce new requirements.

Step 0: Plan and discuss before you build

Agree on the shared contract before splitting up: otherwise two teammates can implement incompatible definitions of the same POI.

You already have the tooling for this from Lab 2: work items with an owner and a label, grouped under a milestone. Create one for this submission the way you created Checkpoint 1, and put a work item under it for each piece of work in the steps below. Reviewers will check the milestone, work items and merge-request/commit history directly in GitLab.

Settle these decisions together before implementing either side (and, for combat, write down when a squad member should hold fire and why):

  • What exactly is a Point of Interest for your team? Which fields it carries, what makes two sightings "the same" POI, and when one stops being true. Write the answer down somewhere your teammates can find it.
  • What does your DAO interface look like? It is the seam the whole lab hangs off: the logic calls it, two implementations have to satisfy it, and your tests are written against it. Agree on it before anyone implements either side, and treat a change to it as something you discuss rather than something you push.
  • Where is the current working set? Decide whether the DAO itself serves your reads or the logic keeps known POIs in memory. Agree on when changes are written through the DAO and how the API sees the same current POIs as the decision logic. Do not introduce a second, unsynchronised copy of the data.

Everything after this point assumes those answers exist. Record them in your work items, together with the alternatives you considered and why you did not take them: that reasoning is the part nobody can reconstruct from the code afterwards.

Assignment: plan the work

Create a milestone for the combined Lab 3–5 submission and work items with an owner and a label, as in Lab 2. Agree on your POI model, DAO interface and working-set ownership; record these decisions and divide the work so teammates can proceed in parallel.

Step 1: Write the tests first

This is the working method from Lab 2, unchanged: a failing test for the behaviour you want, then the smallest implementation that makes it pass, then clean up. It applies to everything you add this lab, and the same red-green cycle should be visible in your git history and your merge requests.

For each change you pick up (rather than writing the entire lab's tests up front):

  • Each new decision rule (recording a POI, removing an exhausted deposit, attacking, calling in from orbit) gets a unit test with its positive, negative and boundary cases, built on the test fixtures you already have.
  • Each new piece of data access gets a test against the interface you are about to define, not against whatever happens to sit behind it. That is what lets you run the same test twice in Step 4.
  • Each new endpoint gets an integration test that drives it over HTTP, like the ones you wrote for the Mission API.

Step 2, Step 3 and Step 4 each call out what is worth testing at that point. Don't postpone them to the end of the lab: a change you cannot test is usually a change you should have made differently.

Which test uses which storage?

Lab 2 gave you JUnit tests for decision rules and Spring Boot tests for HTTP. Here the new question is whether you need to mock the database. You do not. Keep the tests small and use the real H2 database when the mapping itself is under test:

Test Setup Check
Logic rules (*Test) Plain JUnit, a fresh in-memory DAO per test, and your Lab 2 fixtures Sightings, exhausted deposits, remembered decisions, combat and call-ins
In-memory DAO (*Test) Construct it directly; no Spring context Save, mission-scoped read, repeat sightings and removal
SQL DAO (*IT) @SpringBootTest(webEnvironment = NONE) with the SQL DAO, repository and real in-memory H2 The same DAO behaviours, including mission isolation and removal, through JPA
POI API (*IT) Lab 2's RANDOM_PORT setup with the SQL DAO and H2 The HTTP response reflects what logic recorded; filtering and unknown-mission 404

Test your DAO contract through its interface: write a small shared set of assertions that accepts a DAO, then invoke it from separate memory and SQL test classes. This is shared test behaviour, not a mocked repository. The SQL test must exercise Hibernate and H2; verifying only that a DAO calls repository.save() would miss broken mappings and queries. Mockito is available from Lab 2 if a particular logic test needs it, but it is not required for these tests.

To work test-first with JPA, set up the test datasource and minimal interface/entity skeleton so the test runs. Then write one behaviour test (for example, saving a POI for mission A must not return it when querying mission B), watch the assertion fail, and implement that operation. A missing bean or failed database startup is a setup error, not a meaningful red test. Give each test fresh data: Spring can reuse an application context and its H2 database between tests. Clear rows explicitly, or use test transactions for DAO tests; HTTP requests run on the server side, so a transaction on the test method does not roll back those writes. For SQL read-back tests, flush and clear the JPA persistence context before querying where applicable, so you do not just inspect the same managed object. Check the literal columns and values in the H2 console as well.

Step 2: Extend your Mission Logic

Before anything can be stored, something has to produce it. Build POI tracking against the DAO contract agreed with your team; while the data implementation is still in progress, use an in-memory implementation or test double. Combat and call-ins need no database dependency.

Recording Points of Interest

Use the Mission API to record POIs from locationsInView and scan-completed according to the POI rules above. Several squad members can report the same sighting concurrently; make recording safe for that case (and removal too, if you implement it).

Your logic should then actually use what it knows. Remembering where a deposit was is only worth doing if a squad member with room left in its crate heads back towards one it can no longer see.

How you structure this is your call. The naive approach, reading from and writing through the DAO on every action request, is acceptable. Keep reads consistent with writes, and be able to explain when you read from the DAO and when you write to it. If you want to do better, some options (each with a cost):

  • A working set in memory that decisions read from, with the DAO written on change. Fast, but you now own keeping the two consistent.
  • Flush at a turn boundary, for example once all squad members have acted. Fewer writes, but your logic has to track turn state across concurrent requests.
  • Batching, time-gating or a write queue, saving several sightings at once. Fewer round trips, but reads can lag behind writes unless you account for it.

Delayed flushing, eviction and memory-pressure policies are not required. A working set above the DAO is different from the in-memory DAO that you select as an alternative to the SQL DAO.

Attacking

Add a rule that attacks a living hostile organism or rival squad member in range with a usable weapon. See Weapons and the Mission API for the target and weapon fields. Your rule order chooses the action for a member; the backend's Action Priority orders actions from different members.

Not attacking is a valid outcome, for example when a member is low on HP, outgunned by the target, or has a strategic reason to hold fire. "We did not implement combat" is not. You need real attack logic, and you must be able to show your reasoning for the cases where it holds fire: write down in a work item when a member should not attack, make those conditions visible in named code (a clearly named condition or rule, not an accidental fall-through), and unit test at least one deliberate hold-fire case beyond "no target" or "no weapon".

Orbital call-ins

Add at least one rule that reacts to squad state with an orbital call-in, such as a medic kit when HP is low or a jetpack for a long journey. Check availability and the command's preconditions; see Orbital Call-In Commands.

Each call-in has its own cooldown, but that cooldown is shared by the entire colony, and the backend handles squad members concurrently. If two members both see the same call-in as available and both request it in the same turn, only one can succeed. Make sure your logic does not let two members request the same call-in at once, for example by claiming it for the turn before returning the action.

Optional: loot the battlefield

A defeated squad member can leave a lootable corpse behind (see Death & Squad Wipeout). A LootAction picks up a weapon or resources from it, which pairs naturally with the attack rule above. Only pick this up once the behaviour above works and is tested.

Assignment: Points of Interest, combat and call-ins

Extend your Mission Logic so that it records visible resource deposits and living hostile organisms as Points of Interest, as well as large deposits reported by scan-completed, tied to the mission they were spotted in. Tell normal and large deposits apart, and clean up (or mark as stale) the mission's POIs when DELETE /missions/{missionId} ends it. Removing stale deposits and organisms is optional. Use what it remembers in at least one decision.

Add a rule that attacks a living hostile organism or rival squad member within weapon range, with documented and tested conditions under which it deliberately holds fire. Add at least one rule that requests an orbital call-in in reaction to squad state, checking its availability and relevant preconditions, without two members requesting the same call-in at once.

Unit test every one of these, applies and does-not-apply cases (including a deliberate hold-fire case), written before the implementation of each rule.

Step 3: Put a DAO in front of your storage

Your logic produces POIs. Put data access behind a boundary so changing its implementation does not change your decision rules. If you keep a working set in the logic, distinguish it from the DAO implementations and keep it consistent with stored changes.

Introduce a DAO (Data Access Object): an interface that describes the data operations your application needs, in your own vocabulary, with an implementation behind it. Start with an in-memory implementation. In Step 4 a SQL-backed implementation appears, without changing the operations the logic calls.

The layering

This part is prescribed

Lab 2 deliberately left the shape of your decision logic to you. This layering is different: it is the thing this lab is teaching, so it is spelled out, and the assignment expects it.

ControllerLogicdata packageMapRepositorySQL databaseDaoInterfaceDaoMemoryDaoSql

Read it left to right:

  • The controller goes through the logic, not around it. The endpoint you add in Step 5 is a read of the same data your logic manages; if it reached into the DAO itself, you would have two layers that both know where POIs are stored instead of one.
  • The logic decides what POIs mean and gets the DAO injected. It decides when a sighting is worth recording or a deposit is exhausted; it may retain a working set for fast decisions.
  • DaoInterface is the line that does not move. The logic never calls JPA or Spring Data APIs, so it does not have to change when the DAO implementation changes. The shared POI class may carry JPA annotations; keep the API representation separate from it.
  • The dashed boxes are interfaces. DaoInterface is yours; Repository is one you declare but never implement (Step 4 explains why).

A DAO is not a database concept: an in-memory Map is a valid implementation. Selecting memory or SQL storage is a configuration decision. Keeping a working set above either DAO is a separate decision; it does not replace the SQL implementation or its tests.

Move your storage into a data package

Create a data package next to your existing logic and controllers packages for the DAO interface, its implementations, and the POI class. Put the in-memory DAO there. A working set owned by the logic, if you use one, remains in the logic layer.

Define the interface from the operations your own code actually performs, not from what some library happens to offer. Something along these lines, in your own vocabulary:

public interface SupplyCrateDao {

    List<SupplyCrate> findAll();

    void save(SupplyCrate crate);

    void delete(String id);
}

This interface is an example, not a specification. Your POI DAO will need mission-scoped reads rather than this example's findAll, and you are free to shape the operations differently (batch saves, a delete-by-mission, a stale flag, other entities, ...). The only requirement is that it can store and retrieve the Points of Interest this lab needs.

The in-memory implementation is the structure you already had, with an annotation on top so Spring manages it for you. It can use the map techniques you already know:

@Repository // (1)!
public class SupplyCrateDaoMemory implements SupplyCrateDao {

    private final Map<String, SupplyCrate> crates = new ConcurrentHashMap<>(); // (2)!

    @Override
    public void save(SupplyCrate crate) {
        // choose an identity consistent with the SQL implementation
    }

    // the remaining methods: plain Map and Stream work
}
  1. A specialisation of @Component for data access, so Spring can inject it into your logic.
  2. ConcurrentHashMap, not HashMap: the game backend calls your endpoints from several threads at once. Use atomic operations such as putIfAbsent, not containsKey followed by put.

Then rewire your logic to depend on the interface, injected through its constructor, exactly like your controller gets its dependencies injected.

Keep the behaviour

Your logic tests should still pass after you wire in the in-memory DAO. Verify that recording a sighting once, reading it for a mission, and removing an exhausted deposit have the same effect as before.

The unit tests for tracking can hand the logic an in-memory DAO. Test the DAO contract for mission-scoped reads, saving without duplicate sightings, and removing a mission's POIs. Run that contract again in Step 4.

Concurrency

Several members of one mission can report the same sighting or exhausted tile concurrently. Make the in-memory insertion atomic with putIfAbsent. In SQL, enforce sighting identity with an @Table unique constraint and treat a DataIntegrityViolationException on a duplicate save as "already known". For deletion, avoid a Spring Data derived deleteBy...: it loads rows and removes them one by one, so a second concurrent deletion can fail with ObjectOptimisticLockingFailureException. Use a @Modifying @Query("delete from ... where ...") bulk delete instead; deleting zero rows is fine.

Assignment: the DAO layer

Define a DAO interface for mission-scoped Points of Interest and an in-memory implementation in a data package. Inject the interface into the logic, and test its read, save and removal operations. If your logic keeps a working set, explain how it stays consistent with DAO writes.

Step 4: Persist with JPA and H2

Now the second implementation: the same interface, backed by a real SQL database. This is the step where the tooling of this lab (SQL, an ORM, entities, repositories) comes in, and where you get to see how much of it the DAO interface hides from the rest of your application.

Wait, why an in-memory database? Doesn't a Map already do this?

Fair question, worth answering honestly before you set anything up: the H2 database you'll use this lab runs in-memory, and behaves a lot like the in-memory structure you already have. It lives only as long as your application is running and forgets everything the moment it restarts, exactly like a plain Java object would. Nothing about it survives a restart any better than what you already had.

So why bother at all? Two reasons:

  • The tools are the point of this lab, not the storage. SQL, an ORM (Hibernate/JPA), entities, repositories: these are exactly what you'll reach for once your data does need to survive a restart, which happens soon enough (a real, separately-running database server, once you deploy this service somewhere it needs to keep working unattended, in a later lab). Learning those tools against a database that quietly resets itself on every run is simpler and safer than learning them against one you also have to keep alive.
  • A structure you can only inspect from inside your own running code isn't very practical to manage from outside. Even without real persistence yet, a database gives you a structured way to query and edit the data through SQL and a browsable console, independently of your Java code.

Actual persistence across a restart is not achieved by in-memory H2. The game backend can re-send a mission start after the service forgets it, but that does not restore earlier POI sightings. In the later staging lab, a separate PostgreSQL service provides durable storage; recovering a working set from it remains a distinct design question. Do not claim that switching to H2 alone solves restarts.

What's an ORM, and why JPA and Hibernate?

An ORM (Object-Relational Mapper) maps your Java classes onto database tables, and rows in those tables back into Java objects, so you get to work with plain Java objects most of the time instead of hand-writing SQL for every read, insert, update and delete.

JPA (Jakarta Persistence API) is the standard Java interface for this: a set of annotations and interfaces (@Entity, @Id, ...) that any ORM can implement. Hibernate is the ORM that actually implements it, and the one Spring Boot wires up for you automatically once you add the spring-boot-starter-data-jpa dependency below, the same relationship as SLF4J and Logback from Lab 2's logging section: one is the interface you code against, the other is what actually does the work underneath.

Spring Data JPA adds one more layer on top of plain JPA: instead of writing even basic CRUD queries yourself, you declare a repository interface, and Spring generates a working implementation for you at startup, no method bodies to write, the same auto-configuration idea you already saw with Spring Boot itself in Lab 1.

Stacked up, that gives you four layers, and it's worth keeping them straight: your own DAO describes what your application needs, a Spring Data repository turns that into JPA calls, Hibernate turns those into SQL, and H2 runs the SQL. The last three are given to you; the first one you already designed in Step 3.

Add the dependencies

Add Spring Data JPA, the H2 driver, the Spring Boot 4 H2 console and the JPA test starter to pom.xml. H2 is embedded, so this lab needs no separate database server:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-h2console</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa-test</artifactId>
    <scope>test</scope>
</dependency>

The console dependency alone does not activate its endpoint. The console is off by default; DevTools (already present from earlier labs) turns it on during ./mvnw spring-boot:run. See the Spring Boot H2 console documentation.

Configure the datasource

Configure the in-memory datasource in application.yaml, so the SQL DAO works by default during this lab:

spring:
  datasource:
    url: jdbc:h2:mem:colonyopsdb
    driver-class-name: org.h2.Driver

When you move to PostgreSQL in the later staging lab, supply the production datasource configuration there rather than relying on this local H2 URL.

jdbc:h2:mem:colonyopsdb is the in-memory database discussed above: starts empty, forgets everything on restart, and that's fine here. Spring Boot detects the embedded, in-memory database and defaults to create-drop: your @Entity classes are used to generate the schema fresh on every startup, no hand-written SQL schema needed. Under ./mvnw spring-boot:run, browse to /h2-console (connection URL jdbc:h2:mem:colonyopsdb, username sa, blank password unless you configured them otherwise) to inspect the tables Hibernate created and run queries against them by hand. That console is worth keeping open for the rest of this step.

The entity

An entity is a Java class that Hibernate maps onto a table. The example below is not from this lab's assignment; it just shows which annotations do what. A class whose fields are all primitives or enums is the easy case, with each field mapping onto a column directly and no custom converter or nested object anywhere in sight.

@Entity // (1)!
@Table(name = "supply_crate") // (2)!
public class SupplyCrate {

    @Id // (3)!
    @GeneratedValue(strategy = GenerationType.UUID) // (4)!
    private String id;

    private String label; // (5)!

    @Enumerated(EnumType.STRING) // (6)!
    private CrateState state;

    public SupplyCrate() { // (7)!
    }

    // your own constructor, plus getters and setters
}
  1. Marks the class as persistent: Hibernate will create a table for it and map instances onto rows.
  2. Optional. Without it, Hibernate derives the table name from the class name. Naming it yourself is worth doing, because this is the name you'll type in the H2 console and in SQL later on. @Table is also where you'd declare a unique constraint, to let the database itself enforce that some combination of columns occurs at most once.
  3. The primary key. Every entity needs exactly one.
  4. Lets the persistence provider generate the id instead of you. GenerationType.UUID suits a String id; GenerationType.IDENTITY is the equivalent for a database-generated Long (an auto-increment column) if you'd rather use that.
  5. A plain field with no annotation still becomes a column, named after it. Spring Boot's default naming strategy converts camelCase to snake_case, so a field called missionId would land in a column called mission_id; check in the H2 console rather than assuming.
  6. Without this, Hibernate stores an enum as its ordinal number, so reordering the constants in your enum silently reinterprets every row already stored. EnumType.STRING stores the name instead: readable in the console, and stable when the enum changes. Hibernate 7 maps it to a native H2 enum (...) column, which you can see in the console.
  7. JPA instantiates the object first and fills in its fields afterwards, so it needs a no-argument constructor and mutable fields (plain setters). This is why an entity can't be a record, unlike most of the model-lib classes you've been using.

The repository

A Spring Data repository is where you stop writing data access code: you declare an interface, and Spring generates a working implementation at startup. Extending JpaRepository already gives you save, findById, findAll, deleteById and more.

public interface SupplyCrateRepository extends JpaRepository<SupplyCrate, String> { // (1)!

    List<SupplyCrate> findByState(CrateState state); // (2)!

    @Query("select c from SupplyCrate c where c.state = ?1") // (3)!
    List<SupplyCrate> findByStateWithQuery(CrateState state);
}
  1. The two type parameters are the entity and the type of its @Id field. There is no implementation to write, and no @Repository annotation needed: Spring Data finds the interface and generates the bean.
  2. A derived query: Spring Data parses the method name and builds the query from it. findByState becomes ... where state = ?, with the method's parameters filled in positionally. Names compose with And, Or, OrderBy, Between and friends, up to the point where the name stops being readable. The full grammar is in the Spring Data JPA reference.
  3. When a method name can no longer express what you want, annotate the method with @Query and write the query yourself. Note that what you write there is JPQL, not SQL: it talks about the entity and its fields, not the table and its columns.

The SQL-backed DAO implementation

The second implementation of the interface you designed in Step 3. It is thin on purpose: it gets the repository injected and delegates to it.

@Repository
public class SupplyCrateDaoSql implements SupplyCrateDao {

    private final SupplyCrateRepository repository;

    public SupplyCrateDaoSql(SupplyCrateRepository repository) { // (1)!
        this.repository = repository;
    }

    @Override
    public List<SupplyCrate> findAll() {
        return repository.findAll(); // (2)!
    }

    // the remaining methods, equally thin
}
  1. Constructor injection, exactly like your logic injecting this DAO, and your controller injecting the mission state service back in Lab 1.
  2. These method bodies are the only place in your codebase that knows a Spring Data repository is involved at all. Change your mind about that later and these are the only lines you touch.

Where the method bodies here are one-liners, the in-memory implementation had to do the work itself. That contrast is the point: the interface above them did not move an inch, and everything the ORM absorbs on this side reappeared on the other side, in the implementation that has no database to lean on.

Choosing an implementation: Spring profiles

You now have two beans implementing the same interface. Left as is, your application won't even start: Spring finds two candidates for one injection point and refuses to guess. Profiles are how you decide.

A profile is just a named label you switch on at startup. Any bean definition can be made conditional on one with @Profile: Spring only considers the bean when that profile is active, and otherwise skips the class entirely, as if it weren't on the classpath.

@Repository
@Profile("memory") // (1)!
public class SupplyCrateDaoMemory implements SupplyCrateDao { /* ... */ }

@Repository
@Profile("!memory") // (2)!
public class SupplyCrateDaoSql implements SupplyCrateDao { /* ... */ }
  1. Only created while the memory profile is active.
  2. ! negates: created in every other case, including when no profile is active at all. The two conditions are exact opposites, so there is always exactly one bean, never zero and never two.

Which profiles are active is decided outside the code, through the spring.profiles.active property. All of these do the same thing:

./mvnw spring-boot:run -Dspring-boot.run.profiles=memory
java -jar target/logic-service-0.0.1-SNAPSHOT.jar --spring.profiles.active=memory
SPRING_PROFILES_ACTIVE=memory java -jar target/logic-service-0.0.1-SNAPSHOT.jar

You can also set spring.profiles.active in application.yaml, activate several at once by separating them with commas, and annotate a test class with @ActiveProfiles("memory") to run that test under a given profile. Profiles select configuration as well as beans: when memory is active, Spring Boot also loads application-memory.yaml on top of your application.yaml, which is how the same application runs against different settings in different environments. That is what profiles are really for, and the two DAO implementations are a small, visible instance of it.

Both or neither

Get these conditions wrong and the failure is at startup, not at runtime: no matching bean gives you a NoSuchBeanDefinitionException, two matching beans a NoUniqueBeanDefinitionException. Read the bean name in the message; it points straight at the annotation you need to fix.

Start the application both ways in your local environment and confirm your logic behaves the same either way. With the memory DAO, no POI rows should be written to H2; with the SQL DAO, inspect the rows in the console. H2, Hibernate and an empty POI table still start in both profiles: the profile selects a DAO bean, not the whole storage stack. You can see the table creation with logging.level.org.hibernate.SQL=DEBUG.

Don't just test JPA with JPA

A test that writes through your DAO and reads back through that same DAO can pass even when your mapping is subtly wrong: if the same code wrote and read the value, both sides agree with each other without either of them being correct. An enum written as 0 and read back as 0 turns into the same constant you started with, and the test is perfectly happy.

Use the test setups above and check the database independently:

  • Run the same DAO contract against both implementations. The memory implementation shares no code with Hibernate, so a disagreement about mission filtering or removal reveals a bug. Use @ActiveProfiles for a Spring test if needed, or instantiate the memory DAO directly. For SQL read-back tests, flush and clear the persistence context before the read where applicable.
  • Look at the actual table in the H2 console. The column names Hibernate derived, their types, and the literal values in the rows. A round-trip through Java alone will not catch an enum accidentally stored as an ordinal number or an unexpected column name.
  • Stretch: test concurrent writes. Start eight threads with an ExecutorService and a CyclicBarrier; save the same sighting and delete the same tile concurrently. Neither DAO implementation should throw, even when another thread got there first.

Assignment: persistence with JPA and H2

Add a second implementation of your DAO interface, backed by a Spring Data JpaRepository against an H2 database, with the POI class mapped as an entity. Select between the two implementations with a Spring profile, and confirm that your application behaves identically with either one active.

Run your DAO test against both implementations, and check in the H2 console that the columns and values are what you expect.

Step 5: Expose the POIs through an API

Everything so far is invisible from the outside. The last step adds a controller so that "what does my logic know about this mission" is a question anyone can ask over HTTP.

Designing the endpoints

Design them yourself, consistent with the Mission API you already built: sensible HTTP methods and paths, a 404 for a mission that doesn't exist, and the status codes you'd expect elsewhere. At minimum, something that lists the current POIs known for a mission, with an optional filter by type (so "just the known deposits" is one request). When DELETE /missions/{missionId} ends a mission, remove its POIs and any cached working set too; the POI endpoint then returns 404, and a reused mission id starts clean.

Three things to keep in mind:

  • Go through your logic layer, as in the diagram. The controller should ask the component that owns the POIs, not inject the DAO itself.
  • Return a response record, not the JPA entity, so the API shape does not depend on storage or entity constructors and setters.
  • This is a different kind of API than your Mission API. The game backend never calls these endpoints; they exist for you, your teammates, or a dashboard. A later lab will look at locking them down with their own access control, separate from how the game backend authenticates, and another will turn "added/removed" into a live event stream you can watch instead of polling.

Test it the way you tested the Mission API, with an integration test that drives the endpoints over HTTP, including the unknown-mission case.

Optional: deleting a POI

An endpoint that removes a single POI by id is a small addition on top of what you already have, and it makes the API usable for correcting what your logic got wrong.

Future work: pagination

If a later API lists many missions or accumulates many sightings, add bounded pages with a stable order. When reading from SQL, fetch only the requested slice. Pagination is not required for this lab's mission-scoped endpoint.

Trying it out with Postman

The game backend does not call your POI endpoint, so exercise it yourself with the Postman desktop app. The desktop app can reach your service on localhost without the extra agent needed by the browser version. Create a collection for your service, set a collection variable such as baseUrl for its address, and save requests to:

  1. Start a mission and send an action request with a visible deposit, using valid bodies from the Mission API.
  2. List that mission's POIs and filter the list by type.
  3. Request an unknown mission and check its 404 response.

Export the collection as JSON and commit it so teammates and reviewers can run the same requests. The HTTP responses show what your API returns; the local H2 console shows what SQL actually stored. If they disagree, investigate the mapping or working-set synchronization. Postman is for exploration and review; automated integration tests guard the behaviour in CI.

Assignment: a Points of Interest API

Add an endpoint that lists current Points of Interest for a mission, reached through your logic layer rather than the DAO directly, with an optional filter by type.

Cover the endpoint with an integration test, including the behaviour for an unknown mission. Export and commit a Postman collection demonstrating the API and its filter.

Coverage and test quality

Lab 2 deliberately left coverage thresholds out of scope: the goal there was to get a meaningful test suite running in CI at all. You can configure coverage alongside the other workstreams and meet the final threshold as their tests land. Coverage helps identify code and decision outcomes your tests have not exercised, but it cannot prove that a test makes meaningful assertions or that the application is correct.

Measuring coverage with JaCoCo

Add the JaCoCo Maven plugin and configure it with targets of 80% line coverage and 70% branch coverage across production code. These targets are a warning, not a hard gate: the build keeps passing below them (set haltOnFailure to false on the check goal), but the shortfall must be visible in the build output and you should be able to explain it. Line coverage is the single percentage reported to GitLab. Read the JaCoCo Maven plug-in documentation and check goal documentation to configure the rules and bind the check into Maven's lifecycle.

Combining unit and integration-test coverage

Unit and integration tests run in separate Maven phases and normally write separate JaCoCo execution-data files. Collect data for both, merge the files after integration tests, and point the report and check goals at the merged file. Otherwise the threshold may only reflect unit tests. See JaCoCo's integration-test agent and merge goal.

Warning

The course uses Java 25. Older JaCoCo releases may not understand its class files and can fail with an unrelated-looking Unsupported class file major version error. JaCoCo 0.8.15 is verified with JDK 25 and 26; check Maven Central for a current compatible release.

Running ./mvnw verify should generate target/site/jacoco/index.html and print a warning if either target is missed. GitLab's coverage keyword extracts a number from the job log, not directly from JaCoCo's HTML/XML report. Print the merged report's line- and branch-coverage percentages in a predictable format; configure GitLab's regex to extract only the line figure. Make sure both report and check read the merged execution-data file. Expose your minimums as Maven properties so you can verify the warning by overriding one, for example -Djacoco.line.minimum=0.995; see GitLab's coverage documentation.

Optional: enforce the targets as a hard gate

If your team wants the targets enforced, leave haltOnFailure at its default (true) so ./mvnw verify fails when either threshold is missed. To still print the percentages when the gate fails, make the print step run after a failed verify, for example ./mvnw verify || rc=$?, then print and exit ${rc:-0}.

Use coverage to find untested code and decision branches, then improve tests by asserting meaningful outcomes and alternatives. Do not add tests solely to make the percentage rise.

Assignment: coverage report

Configure JaCoCo to combine unit and integration-test execution data, with targets of 80% line coverage and 70% branch coverage across production code that warn (not fail) when missed. Show the warning appears when a target is overridden above your actual coverage. Print both final percentages in the GitLab job log; extract only the line percentage with coverage:. Explain any gap you leave.

Removing dev tools from the container image build

The Jib image built in Lab 2 can include spring-boot-devtools even though Spring Boot normally excludes it from a repackaged JAR. Add the Jib Spring Boot extension so Jib excludes DevTools too. Add a plugin dependency inside jib-maven-plugin and enable the extension in its configuration (check Maven Central for the current extension version):

<dependencies>
    <dependency>
        <groupId>com.google.cloud.tools</groupId>
        <artifactId>jib-spring-boot-extension-maven</artifactId>
        <version>0.1.0</version>
    </dependency>
</dependencies>
<configuration> <!-- merge into your existing Jib configuration -->
    <pluginExtensions>
        <pluginExtension>
            <implementation>com.google.cloud.tools.jib.maven.extension.springboot.JibSpringBootExtension</implementation>
        </pluginExtension>
    </pluginExtensions>
</configuration>

Run ./mvnw compile jib:buildTar to create target/jib-image.tar without a registry or daemon. Extract the archive and inspect its layer tarballs for app/libs/: before adding the extension, spring-boot-devtools-<version>.jar is there; afterwards it is absent. Load the image with docker load -i target/jib-image.tar, run it with a published application port, and check curl -i localhost:<port>/h2-console: it should return 404. Run the same image again with -e SPRING_H2_CONSOLE_ENABLED=true (after stopping the first run); the same request must return 302. Compare with ./mvnw spring-boot:run, where it also returns 302. The forced-on control proves that the default 404 is not merely a mistyped path.

The console jar remains in the image, but a dependency on the classpath does not activate the endpoint: DevTools does that locally, and the extension removes DevTools from the release image. Because the console classes are still present, enabling it in a release (for example with SPRING_H2_CONSOLE_ENABLED=true or by letting DevTools back into the image) exposes it. The safety here is the default, not the absence of console code.

Assignment: release image hygiene

Verify that the Jib image contains no DevTools jar and /h2-console is not reachable in it by default, while it is reachable under spring-boot:run. Show the layer inspection and the forced-on image control.

Practicalities

Lab 3 has no deadline of its own. Its deliverables are assessed together with those of Lab 4 and Lab 5, in the combined submission due on Tuesday, 3 November 2026; see the lab overview for all lab dates and deadlines.

Warning

All support outside of the physical Lab session is handled via GitLab work items (Plan > Work items). Learn more about the issue tracker and how to request support here. Do not expect support outside of the normal office hours (Monday to Friday, from 9 AM until 5PM).

Checklist

  • Create a milestone for the combined Lab 3–5 submission and work items with owners and labels; divide the work after shared planning.
  • Agree as a team on the POI model, DAO interface and working-set ownership; record when data is read and written.
  • Write the test before the implementation for every behaviour below, with the red-green cycle visible in your git history.
  • Record visible deposits and scanned large deposits, plus living hostile organisms, as mission-scoped Points of Interest.
  • Remove (or mark as stale) a mission's POIs when DELETE /missions/{missionId} ends it. Detecting and removing depleted deposits and defeated organisms is optional.
  • Use what your logic remembers in at least one decision.
  • Extend your Mission Logic with an attack rule (living hostile organism or rival squad member in weapon range), and document and test when it deliberately holds fire.
  • Extend your Mission Logic with at least one orbital call-in rule, checking cooldown availability and relevant preconditions, and avoiding two members requesting the same call-in at once.
  • Unit test the new tracking, combat and call-in rules, applies and does-not-apply cases.
  • Define a DAO interface for Points of Interest and put its in-memory implementation in a data package.
  • Inject that interface, not an implementation, into the logic that records and reads POIs.
  • Add a second implementation backed by a Spring Data JpaRepository against H2, with the POI class mapped as an entity.
  • Select between the two implementations with a Spring profile, and confirm the application behaves identically with either one active.
  • Run your DAO test against both implementations, and verify the stored columns and values in the H2 console.
  • Add an endpoint listing the current POIs for a mission, with an optional filter by type, reached through your logic layer.
  • Return response records from the POI API; delete mission POIs and working-set state when the mission ends.
  • Cover that endpoint with an integration test, including the unknown-mission case.
  • Export and commit a Postman collection for the POI API, including a type-filtered request.
  • Add JaCoCo to the build, merging unit and integration-test execution data, with 80% line and 70% branch coverage as warning targets. Confirm ./mvnw verify warns when a target is missed, and explain any gap.
  • Print line and branch percentages from the merged JaCoCo report in the GitLab job log; extract the line figure with coverage:.
  • Verify the Jib image contains no DevTools jar and /h2-console is disabled by default (enabled with the forced-on control), while spring-boot:run exposes the console.