DEV Community

Said Olano
Said Olano

Posted on

Spec Driven Development

Why I wrote the spec before the code

Most CRUD APIs I have reviewed in my career share the same history. The first controller appears on day one, the entity grows fields as the team talks, and the tests arrive later, often shaped around whatever the code happens to do. Nobody can say, with confidence, what the API is supposed to do when an email is already taken, or whether a deleted customer should still be readable. The answers exist, but they live in the heads of the people who wrote the code, and they drift every time someone touches the endpoints.

Spec-Driven Development (SDD) is a simple response to that problem. You describe each operation in a written specification first, you trace every rule back to a test that names the spec item, and only then do you write the implementation until the tests pass. The idea is old, but it fits modern Java services unusually well, because Spring Boot already gives you the layers and the test tooling to make the trace visible.

In this article I walk through a small project I built this way: Customer_SDD, a customer CRUD REST API. The project is public, and its documentation site describes the workflow in a few short sections. Here I want to go further: explain why each phase exists, show how the spec, the tests, and the code relate to one another, and point out the trade-offs I noticed along the way.

The project in one paragraph

Customer_SDD is a REST API for managing customers. It uses Java 21, Spring Boot 3.5.4, Spring Data JPA, Bean Validation, an in-memory H2 database, and Maven. Error responses follow RFC 7807 ProblemDetail, so clients receive a consistent JSON shape for failures. The application has five operations, each with a stable identifier:

  • CUST-01: create a customer with POST /api/customers. It returns 201 on success, 400 on invalid input, and 409 when the email is already in use.
  • CUST-02: read one customer with GET /api/customers/{id}. It returns 200 even for inactive customers, and 404 when the id does not exist.
  • CUST-03: list customers with GET /api/customers?active=, sorted by last name.
  • CUST-04: update a customer with PUT /api/customers/{id}. It returns 200, 400, 404, or 409.
  • CUST-05: delete a customer with DELETE /api/customers/{id}. The delete is logical and idempotent, and it returns 204, or 404 for unknown ids.

The layering is deliberately plain: CustomerController calls CustomerService, which calls CustomerRepository, which talks to H2. Nothing surprising there, and that is the point. The interesting part is not the architecture diagram. It is the document that sits in front of all of it.

Phase 1: Spec

The specification lives in a single Markdown file, docs/specs/customer.md. It is the only specification file in the project, and it defines three things: the entity, the operations, and the error format.

The entity section answers the questions that usually get settled accidentally. Emails are trimmed, stored in lowercase, and must be unique. That sentence sounds trivial, but writing it down forces a decision. If the team had not decided, two developers could reasonably implement Ana@Example.com and ana@example.com as different customers, and nobody would notice until a support ticket arrived.

Deletion is another example. The spec says deletion is logical: the record is kept and marked inactive. That single decision changes the behavior of four operations. Reading a deleted customer by id must still work (CUST-02 returns 200 for inactive customers). Listing with active=false must surface them. Deleting twice must not fail, because the operation is idempotent. Updating a deleted customer must be a conscious choice, not an accident of how the JPA entity happens to be loaded. None of these consequences is hard to implement once you know them. They are very hard to discover after the fact.

Each operation in the spec lists its status codes explicitly. This matters more than it looks. When a status code is missing from the document, the implementation tends to return whatever Spring returns by default, and that default is often a 500 for a case you meant to handle as a 409. Writing 409 on duplicate email next to the endpoint makes the duplicate-email rule a first-class part of the contract, not an exception path nobody planned.

The error format is also defined centrally. Validation errors include a per-field errors map, which means a client submitting a form can highlight the exact fields that failed. Because the format is part of the spec, the tests can assert it, and the controller advice that produces it becomes a small, obvious piece of code.

What makes a good spec item

After writing this kind of spec several times, I have settled on a few properties that every item should have:

  1. A stable identifier. CUST-04 is easy to say in a code review, easy to search for, and easy to keep when the wording changes. Titles change; identifiers should not.
  2. Observable behavior only. The spec describes what a client sees: status codes, bodies, and side effects that matter. It does not prescribe which repository method to call.
  3. Every failure case named. If you cannot list the 4xx responses an operation can produce, the operation is not specified yet.
  4. Decisions, not descriptions. "The email is unique" is a description of a constraint. "A duplicate email returns 409 and does not create a record" is a decision a test can check.

Phase 2: Test

The second phase turns the spec into executable checks. The rule is simple: each test names the operation it verifies. In this project the operation identifier appears in the @DisplayName of the test, and the project contains 14 tests that cover every rule in the spec.

Here is the shape of a test for CUST-01. The code is illustrative, written to show the pattern rather than copied from the repository:

@SpringBootTest
@AutoConfigureMockMvc
class CustomerCreateSpecTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    @DisplayName("CUST-01: POST creates a customer and returns 201")
    void createReturns201() throws Exception {
        mockMvc.perform(post("/api/customers")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    {"firstName": "Ana", "lastName": "Lopez",
                     "email": "  Ana.Lopez@Example.com  "}
                    """))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.email").value("ana.lopez@example.com"));
    }

    @Test
    @DisplayName("CUST-01: POST with a duplicate email returns 409")
    void duplicateEmailReturns409() throws Exception {
        // First creation succeeds, the second must be rejected.
        // The assertions follow the spec: 409, no second record created.
    }
}
Enter fullscreen mode Exit fullscreen mode

Two things are worth noticing. First, the email normalization rule is tested with the messy input from the spec, leading and trailing spaces and mixed case, so the test checks the exact behavior the spec promises rather than a happy path. Second, the test name carries the spec identifier, which means a failing build reads like a failing requirement: CUST-01: POST with a duplicate email returns 409 tells a reviewer exactly which promise was broken.

The MockMvc approach matters here. These tests exercise the full HTTP contract: routing, deserialization, validation, the service, the repository, the database, and the error mapping. They are not unit tests of a single method. For an API whose main promise is a contract, that is the right level.

Tests as the bridge

The tests are the bridge between two documents that otherwise diverge: the spec and the code. A reviewer can read customer.md, then search the test sources for CUST-04, and immediately see whether every rule has coverage. A new developer can do the reverse, starting from a failing test and finding the spec item that justifies it. The traceability is not a separate tool or a generated report. It is the convention of putting the identifier in the test name.

Writing the tests before the code

In SDD the tests are written against the spec before the implementation exists, and they fail first. This is the step that most teams skip, and it is the one that gives the phase its value. A test that has never failed can be passing for the wrong reason, for example because the endpoint returns 200 for every request. Watching CUST-05 fail with a 404 before the delete endpoint exists proves that the test is actually checking something.

Phase 3: Code

The last phase is the one most developers already know. The controller, service, and repository change until mvn test passes. The discipline is that the code is only written to satisfy named tests, which keeps it from growing features nobody specified.

The layers have clear responsibilities in this design:

  • The controller maps HTTP to operations. It handles the status codes and the request and response shapes, and it delegates everything else.
  • The service holds the business rules: email normalization, uniqueness checks, and the rule that a logical delete only flips a flag.
  • The repository is a Spring Data JPA interface. It stores and retrieves customers, and it is where the sorted listing for CUST-03 is expressed.

The logical delete shows how the spec shapes the code. CUST-05 says the delete is logical and idempotent. The service therefore does not call a physical delete. It loads the customer, marks it inactive if it is not already, and returns. A second call finds the customer already inactive and does nothing harmful. Without the spec, a developer would reasonably write repository.deleteById(id), and the idempotency rule would fail the first time someone deleted an id twice.

Here is a simplified version of that service logic, again illustrative:

@Service
public class CustomerService {

    private final CustomerRepository repository;

    public CustomerService(CustomerRepository repository) {
        this.repository = repository;
    }

    @Transactional
    public void deactivate(Long id) {
        // CUST-05: logical and idempotent delete.
        Customer customer = repository.findById(id)
                .orElseThrow(() -> new CustomerNotFoundException(id));
        customer.setActive(false); // already inactive? no-op, still 204
    }
}
Enter fullscreen mode Exit fullscreen mode

The CustomerNotFoundException maps to a 404 through the shared error handling, which keeps the controller free of repetitive exception logic.

The workflow as a loop, not a waterfall

It would be easy to read the three phases as a waterfall: write the whole spec, write all the tests, then write all the code. In practice I used them as a short loop, one operation at a time. Pick CUST-01, write or refine its spec item, write its tests and watch them fail, implement until they pass, then move to CUST-02. The spec stays the single source of truth, but it is not frozen. When implementing CUST-02 revealed that inactive customers must still be readable, I went back to the spec, added that decision explicitly, and only then changed the code and the tests.

That back-and-forth is healthy. What SDD forbids is changing the behavior without changing the spec first. The rule is about order of authority, not about avoiding change.

Running it

The project keeps the operational side simple. mvn test runs the spec tests, and mvn spring-boot:run starts the application at http://localhost:8080. A sample curl POST creates a customer, which is a good smoke test after any change. Because H2 is in memory, every run starts with an empty database, so the tests do not depend on leftover data from earlier runs.

The fact that the test command is named "spec tests" in the documentation is a small detail with a real effect. It reminds the team what the tests are for. They are not a coverage target. They are the executable form of the agreement.

Trade-offs I noticed

SDD is not free, and I want to be honest about the costs.

Upfront writing takes time. A spec for five operations is a few pages. For a larger system, the spec becomes a real artifact that needs maintenance, and teams that treat it as paperwork will see it go stale. The cure is to keep specs close to the code, in the same repository, reviewed in the same pull requests.

Specs can encode the wrong decision with confidence. A written rule looks authoritative, even when nobody validated it with the people who will use the API. SDD does not replace conversations with product owners or consumers. It makes the outcome of those conversations visible and testable.

Identifiers need discipline. Once CUST-04 appears in tests, dashboards, and conversations, renumbering it is expensive. I found it better to keep identifiers stable and retire operations explicitly than to reuse numbers.

Tests can drift from the spec too. The convention of putting identifiers in test names only works if reviewers check it. Nothing enforces that a test name matches a real spec item. For a larger project I would add a simple script that cross-checks the identifiers in the spec against the test sources, so a missing or orphaned identifier fails the build.

Error contracts deserve more attention than they usually get. Because the spec defines the error format and the status codes, the error handling becomes part of what you test. That is a good thing, but it means error handling cannot be an afterthought in an SDD project.

When I would use this approach

SDD pays off most in these situations:

  • The API is a contract with external consumers, so behavior changes are expensive.
  • The business rules are subtle, such as idempotency, soft deletes, and uniqueness with normalization.
  • Several people will change the code over time and need a shared, written reference.
  • The team wants a clear path from a requirement to a test to a line of code.

For a throwaway prototype or an exploratory spike, the ceremony is probably not worth it. The point is not to write documents for their own sake. The point is to make decisions explicit at the moment they are cheap to change.

Key takeaways

  • Write each operation as a spec item with a stable identifier, observable behavior, and every failure case named.
  • Name the spec identifier in each test, so a failure points to a broken requirement.
  • Watch the tests fail before implementing, to prove they check something real.
  • Let the spec govern the code: when behavior changes, change the spec first.
  • Keep the specification in the repository, review it with the code, and automate the traceability when the project grows.

Customer_SDD is a small project, and its value is mostly as a reference for the workflow rather than as a product. If you want to see the full spec, the tests, and the code together, the repository and its documentation site are public. Reading the three side by side is the fastest way to understand how the phases connect.

Read more details: {DEV_TO_URL}

Top comments (0)