DEV Community

Cover image for Spec-Driven Development in Practice: Porting RoomBook from Spring Boot to NestJS
rosidotidev
rosidotidev

Posted on

Spec-Driven Development in Practice: Porting RoomBook from Spring Boot to NestJS

In the second article I built RoomBook with Spec Kit: an Angular frontend, a Spring Boot backend, SQLite, and five feature cycles, each with its own spec, plan and tasks. I finished with a question: can the backend be replaced by another stack while the contract, the tests and the frontend stay fixed?

This article is the answer I got, and the answer took more than one prompt. The new backend is written in NestJS on Node.js with TypeScript, in backend-nest/, next to the old one. Getting there meant a constitution amendment, a specification, a plan, four implementation phases with checkpoints, an amendment in the middle of the work, and a parity check against the original service. Everything ran on the same repository and under the same constitution, with one new feature folder for the migration.

The toolkit is GitHub Spec Kit, and the whole project, with the specifications from the first two articles and the migration described here, is in my room_book repository, under specs/. As before, this is a personal account, and I quote the command outputs where they matter.

The Goal, Stated as Constraints

A migration has an easy version of the goal, "rewrite the backend in the new stack", and a hard version, "users must not notice". I took the hard one and wrote it down as constraints:

  • The HTTP contract stays the one in openapi.yaml, byte for byte.
  • The business rules stay the ones already specified: no overlaps, opening hours, 15-minute slots, a 4-hour maximum, no bookings in the past, cancellation up to one hour before the start, and disabled rooms not bookable.
  • Errors keep the application/problem+json format and the same status codes.
  • The data already in the SQLite file must still be readable.
  • The Angular frontend is not modified.

Most of the work came from taking these lines seriously. If the frontend changes, the migration has failed, even when the new backend works perfectly.

Two Decisions Before the First Command

Two decisions were written down before the first Spec Kit command, so that anyone running the process could see them.

The first is about data. Two backends must never write to the same SQLite file at the same time, so the new service works on a copy of roombook.db. The original file stays where it is, untouched, and I keep its SHA-256 hash to prove it.

The second is about the cutover. Removing backend/ and renaming backend-nest/ is a separate change, and I decided it should be an explicit choice, made after parity, not a step inside the feature. I wrote this into the specification as an assumption, so that no task could quietly remove the old backend.

Step 1: Constitution, as a Minor Amendment

The constitution from the second article described a Spring Boot stack, Flyway and a monorepo with backend/. A migration breaks some of those statements, so the first command amended the file instead of replacing it:

/speckit-constitution Amend the RoomBook constitution for the backend migration from Spring Boot
to NestJS. Changes: (1) Technical constraints: the backend is NestJS on Node.js LTS with
TypeScript, in backend-nest/ ... (2) Principle V: schema and seed changes go through versioned
SQL migrations, run by an internal runner; Flyway is no longer used. (3) Principle VII: backend/
remains only during the migration and is removed at cutover. All other principles stay unchanged.
Bump the version as a MINOR change and record the rationale in the Governance section.
Enter fullscreen mode Exit fullscreen mode

Notice what the prompt does not do: it does not rewrite the principles. Contract first, the injectable clock, the service-layer rules, the shared design system: all of them stay. The amendment only touches what the migration really changes, and the version number tells the reader that the change is a MINOR one. The rationale goes into the Governance section, so the reason is recorded where the next person will look.

The check is simple: the file contains the new stack, the version went up, and the rationale is there.

Step 2: Specify, Without a Single Framework Name

The specification is the one place where I was strict. A specification for a migration is still a specification of behavior, so it must not mention the technology:

/speckit-specify Replace the RoomBook backend with a new implementation in backend-nest/, without
changing visible behaviour. The HTTP contract defined in openapi.yaml stays unchanged and the
frontend is not modified. The business rules stay identical. Errors stay in application/problem+json
format with the same status codes. The data already present in backend/data/roombook.db must be
readable by the new backend through a copy of the file. No new feature is included.
Enter fullscreen mode Exit fullscreen mode

The result is specs/006-nest-backend-replacement/spec.md. The check I give myself for every spec is short: no stack names in the requirements, and no [NEEDS CLARIFICATION] marker left behind. A migration makes this rule harder to keep, because the temptation to write "use the same Express middleware" is strong. I kept it out.

The optional clarify step is where I wanted the questions most: how the copy is made, what happens with a database that has not been migrated yet, and how parity is checked. Those are the questions a migration hides.

Step 3: Plan, the Place Where the Stack Enters

Now the stack goes in, and it is the same idea I described in the first article: the spec says what, the plan says how.

/speckit-plan Backend in backend-nest/ with NestJS on Node.js LTS and TypeScript. Module structure:
rooms and bookings, each with a controller (HTTP mapping only), a service (business rules) and a
repository (SQLite access). Persistence with better-sqlite3 and the versioned SQL files V1–V4 reused...
Tests: Jest with supertest for the contract tests, which validate requests and responses against
specs/001-project-setup/contracts/openapi.yaml and every documented status code.
Enter fullscreen mode Exit fullscreen mode

Two things in this prompt matter more than the rest.

The first is reuse of the migrations. The SQL files V1 to V4 are copied as they are. The Spring service uses Flyway, which keeps its own history table, so the new service must verify that history without changing it. The plan therefore splits the job in two: a read-only check of Flyway's table at startup, and a separate table for future migrations (V5 and later). The research file records this as a decision, R-003, with its reason: the copy's history must not change.

The second is the clock. Every time-dependent rule uses an injectable Clock, as the constitution asks. For the parity runs, the new service can be frozen with an environment variable, while the Spring service keeps the real clock. That sounds odd at first, but it makes the recorded requests robust against time: a request about "tomorrow at 10:00" means the same thing on both sides, and it does not depend on when the test runs.

The planning step produced plan.md, research.md, data-model.md, contracts/README.md and quickstart.md. The check I run on the plan is the one from the second article: no reference to Spring in the implementation choices. The Spring code is there only as a reference for the rules, and the plan says so.

The plan also records the versions I checked on the day: NestJS 12, better-sqlite3 13 (which requires Node 22 or later) and Jest 30. The local runtime was Node 24. Writing the versions down is a small habit that saves a lot of confusion later.

Step 4: Checklist and Tasks, Test First

The checklist is an optional gate. Spec Kit does not require it, and it was tempting to skip it, because the spec already comes with its own requirements checklist. I ran it anyway, with an explicit focus, because the questions this migration could hide are about compatibility, not about the code:

/speckit-checklist Quality of the parity requirements: unchanged contract, complete business rules,
error format, compatibility with the existing database
Enter fullscreen mode Exit fullscreen mode

The checklist does not change the spec and does not inspect the code. It produces questions about the requirements, written as if a reviewer had never seen the implementation: is every rule complete, is every error stated, is the database compatibility defined well enough to be tested? A person answers them. In this project the checklist belongs to the reviewer, so it is something I read and judge, not something I let the agent tick off. Before implementation, the requirements checklist had 16 items, all checked, and the implement command reads that state as a gate before it starts.

Then came the tasks, which is where the method turned into test-first development. I chose TDD on purpose, and I wrote it into the order of the phases instead of leaving it to the agent:

/speckit-tasks Order the tasks in phases: (1) scaffolding of backend-nest/ and the migration runner;
(2) contract tests for each endpoint, written before the code; (3) implementation of the rooms and
bookings modules; (4) parity comparison with the Spring backend on the same copied database.
No task modifies openapi.yaml.
Enter fullscreen mode Exit fullscreen mode

The four phases each have a precise role:

  1. Scaffolding and migration runner. The runner's tests were written before the runner. The first red tests asked a simple question: does the service refuse to start on a missing or mismatched database?
  2. Contract tests, red by design. This phase has no controller and no service. Every endpoint and every documented status code gets a test that validates against openapi.yaml, and all of them fail at the start. The implement instruction is explicit that they must fail because the feature is missing, not because of a setup error. A test that is red because of a broken import looks like progress, and it is not, so I read the failure messages myself.
  3. Implementation, test first again. The booking rules were written as tests before the rules themselves. Only then did the rooms and bookings modules appear, and the tests turned green one at a time. The boundaries of the rules were checked with a fixed clock, so the results were deterministic.
  4. Parity. The same requests are sent to both services, on identical copies of the database, and the responses are compared.

Phase 2 is the one I would point to. The contract tests were the specification in executable form, and they were red before a single line of the new backend existed. Later, when the amendment moved DTOs and transactions into their final places, the same suite was the check that nothing visible had changed.

The analysis step, run before implementation, checked two things that I wanted to be sure of: no task touches openapi.yaml, and every endpoint has a contract test. It found no critical inconsistency.

Step 5: Implement and Converge, One Phase at a Time

Each phase follows the loop from the second article: implement, converge, repeat until converge says "Converged", and check the phase checkpoint before moving on. The implement argument repeats the hard limits every time, because each run starts with a fresh context and I do not want to rely on memory:

/speckit-implement Only phase 2 (T021–T034). ... Do not implement any controller or service in this
phase. The new route tests must fail because the feature is missing, not because of a setup error.
Do not modify openapi.yaml, frontend/ or backend/.
Enter fullscreen mode Exit fullscreen mode

The sentence about the failing tests is the one I would copy into any migration. A test that fails for a setup error looks like progress, and it is not. I check the failure messages myself: an import error or a wrong path means the phase is not done.

Some things I learned from the runs, which the tasks alone would not have shown:

  • import type for interfaces. With decorators, emitDecoratorMetadata and ts-jest in ESM mode, importing an interface as a normal value fails at load time. It was a small error, found by the first test run, and it is now a rule in the decision log.
  • Shared state in tests. The overlap test in the booking contract file failed only when it ran after another test. Alone it passed. The failure came from a shared app and database, not from the rules. The fix gives that single test its own app and its own database, without changing any expectation: 201 for the first booking, 409 "Slot already booked" for the overlap.

This second point is the one I want to underline. When a test fails, the first question is whether the code is wrong or the test is wrong. In this project, a test expectation that looks wrong must be reported, not edited, and the migration instructions say so explicitly.

An Amendment in the Middle of the Migration

Halfway through, I wanted two changes that had nothing to do with the migration's first goal. I wanted one DTO file per module, following the pattern of the earlier features, and I wanted database transactions to belong only to the service layer, as the constitution already says. Neither change alters the contract or the rules, but both alter the structure.

I did not open a new feature for this. I used the existing one, with the same Spec Kit sequence, and each command was given an explicit folder so that nothing would be created by mistake:

/speckit-specify Amendment to feature 006 (backend-nest replacement), no new user-facing behaviour: ...
/speckit-plan Amendment to the existing plan of feature 006 (do not create a new feature)...
/speckit-tasks Amendment to the existing tasks of feature 006... Append a new phase after the existing
tasks; do not renumber or edit existing completed tasks.
/speckit-implement Only phase 5 (T061–T068)...
Enter fullscreen mode Exit fullscreen mode

The results were a new requirement pair in the spec (FR-022 for the DTOs, FR-023 for transactions), an updated plan and data model, and a Phase 5 appended to tasks.md. Existing tasks were not renumbered. That detail matters more than it looks: the task list is a log, and a log that gets rewritten is no longer a log.

The amendment also gave me a useful example of how converge behaves. After the phase, converge found one high-severity gap, the shared-state overlap test I described above, and one medium gap: the parity gate had not been run yet. It did not fix either. It appended Phase 6, with one task per gap, and the loop closed them.

Every decision taken without asking went into a decision table, with an ID, the decision, and a short reason. The table is short, so I can review it in two minutes. For example, D-2 moved the BookingSummary type into the bookings DTO file rather than the rooms one, because its mapping belongs to the booking shape. That is the kind of choice I would have made silently in a normal project.

The Parity Gate, and Where I Stopped

The last phase is the one that decides whether the migration is real: the same requests, sent to the Spring service and to the NestJS service, on identical copies of the database, must produce identical responses.

The harness has three rules. It resets both copies before each run, so a run cannot depend on a previous one. It uses a fixed set of recorded requests, 99 in this run, chosen to be robust against time. And it compares the status, the headers that matter and the body, byte by byte where the format is part of the contract.

The gate command is simple:

npm --prefix backend-nest run parity
Enter fullscreen mode Exit fullscreen mode

The gate ran to completion. Before the final run I re-recorded the Spring reference against the live Spring service, because the earlier reference file had been removed. The recording holds 99 requests. Then the gate replayed them on fresh copies of the database:

Fresh copies made from backend/data/roombook.db (sha256 2e10fed8…cd44).
Compared 99 requests (Spring on http://127.0.0.1:8090, Nest on http://127.0.0.1:8091).
Differences: 0. Spring drift vs recorded: 0.
Enter fullscreen mode Exit fullscreen mode

The harness starts both services itself, on ports 8090 and 8091. The ports in the early plan were 8080 and 8081, so the harness was adjusted to the run rather than the other way round.

Zero differences and zero drift are the result that matters. The checks around it are green too: the NestJS suite passes 15 of 15 suites and 72 of 72 tests, openapi.yaml shows no diff, the frontend/ and backend/ folders show no diff, and the SHA-256 of the original database is unchanged. With this run, the last two tasks, T060 and T070, are done. The cutover, removing backend/ and renaming backend-nest/, remains a separate decision, as the specification requires (FR-021).

What the Process Gave Me

After a second project with Spec Kit, here is what I would add to the list from the previous articles:

  • A migration becomes a checklist of constraints. Writing "no change to the contract, the rules, the errors and the frontend" forced every later step to name what it must not touch.
  • Tests that fail for the right reason. The phase 2 instruction is the most valuable sentence in the whole set of implement instructions. A red test is only progress when it is red for the expected reason.
  • Decisions are visible. The decision tables, the R- and D- entries, made it possible to review what the agent decided on its own, instead of finding it in the code later.
  • The contract as a fixed point. A SHA-256 check before and after, and a rule in every prompt, kept openapi.yaml untouched through the four phases and the amendment.

What the Process Taught Me

The other side, with the same sincerity as before:

  • The parity set is a deliberate, bounded choice. For this exercise, 99 recorded requests were enough to cover the rules and error paths of the current API, and the gate compares the two services on each of them. In a production setting, the same harness could replay many more requests, including generated edge cases, because adding a case is just adding an entry to the file. The number is a starting point we chose, not a limit of the method.
  • The spec is only as good as the questions asked. Clarify found the copy strategy and the unmigrated-database case. It did not find, by itself, every behaviour that the old code had and nobody wrote down. That is why the parity harness matters.
  • Good instructions take time to write. Each command carried its own limits, checks and expected outputs, which is what made the process repeatable. Writing them was real work, and the cost is time, not tokens.
  • A person still decides the cutover. The agent can make the new backend pass its tests. It cannot decide that removing the old one is safe.

Takeaways

  1. Constraints are the specification of a migration. The fixed points, the contract, the rules, the errors and the frontend, are what make the change measurable.
  2. Phase order is the safety net. Scaffolding, then tests that fail for the right reason, then code, then parity.
  3. Amendments belong to the existing feature. A log with numbered tasks and decision tables is easier to trust than a rewritten one.
  4. Passing tests are not parity. The gate that matters compares two running services on the same data. Once it reports zero differences, the migration has evidence behind it, not only green tests.

The migration itself is in the repository: the specification and plan in specs/006-nest-backend-replacement/ and the code in backend-nest/. The one decision left is the cutover, which is a separate change, and it is my call.

What Comes Next

With this article, the series on GitHub Spec Kit comes to an end. The next one is about the BMAD Method. I am studying it right now, and writing about what I study is one of the best ways I know to learn it, especially when each concept is tied to a practical example from a real project situation. BMAD is similar in spirit to Spec Kit, but more complex: it has a steeper learning curve, and in exchange it covers a wider part of the software lifecycle. Stay tuned.

Have you tried changing the stack of a project that has a contract, without touching the frontend? What did you keep fixed, and what did you find out only at the end? I would like to read your experience in the comments.

Top comments (0)