TL;DR: Annotate your JPA entities with
@Seed(count = n), start your Spring Boot app in a dev profile, and the tables are populated in dependency order with realistic, constraint-respecting data. No seed script.io.github.thembatman0:synthforge-spring:0.1.0on Maven Central.
You've added a second entity to the schema, wired up a @ManyToOne, and gone back to your seed script to generate fifty more rows. Ninety seconds later, the app refuses to start: unique constraint violation, somewhere inside a loop you wrote three weeks ago at 11pm. You fix it. You restart. A different field breaks a different constraint.
This is the exact moment every Spring Boot developer eventually meets the real limit of tools like Faker. They're brilliant at generating a name, an email, an address. They have no idea the Payment sitting in front of them needs a Counterparty to already exist.
So you do what everyone does: hand-write the wiring. Create parents first. Hold onto their generated IDs. Wire them into children. Hope you didn't just violate a @NotNull somewhere in the process. It works, for a while. Then the schema changes, and the script quietly stops matching reality until the next 3am debugging session finds out the hard way.
I hit this enough times that I stopped patching the script and looked at the actual problem: the information needed to seed this correctly already exists. It's sitting right there in the entity, in the annotations you already wrote. @ManyToOne, @NotNull, @Column(unique = true), JPA already knows the shape of your data. Nothing should need to be told that twice.
That became SynthForge.
The core idea
Instead of writing a script that generates data, you annotate the entity:
@Entity @Seed(count = 50)
public class Counterparty { /* fields only */ }
@Entity @Seed(count = 200)
public class Payment {
@ManyToOne(optional = false) private Counterparty counterparty;
}
Start the app in a dev profile. Both tables populate, correctly ordered. Restart it and tables that already have rows are skipped. No seed method. No calling code, anywhere. The entity is the seed script.
Try it in two minutes
The repo ships a small demo app (Counterparty and Payment on an in-memory H2 database), so you can see it work before touching your own project. You need Java 21 and Maven.
git clone https://github.com/ThembaTman0/synthforge.git
cd synthforge
mvn -pl synthforge-demo -am install -DskipTests
mvn -pl synthforge-demo spring-boot:run -Dspring-boot.run.profiles=dev
Along with the SQL it logs, you should see:
SynthforgeAutoConfiguration : Seeding with random seed 1593487635758507864 (set synthforge.seed to this value to reproduce the run)
SynthforgeAutoConfiguration : Seeded 50 Counterparty rows
SynthforgeAutoConfiguration : Seeded 200 Payment rows
The seed value changes on every run. Set synthforge.seed to a fixed number and you get identical data each time, which helps when you're debugging. The demo has no web layer, so it seeds, logs and exits.
To use it in your own app:
- Add the dependency (live on Maven Central):
<dependency>
<groupId>io.github.thembatman0</groupId>
<artifactId>synthforge-spring</artifactId>
<version>0.1.0</version>
</dependency>
or, with Gradle:
implementation 'io.github.thembatman0:synthforge-spring:0.1.0'
- Put
@Seed(count = n)on your entities. - List the profiles that should seed under
synthforge.enabled-profiles:
synthforge:
enabled-profiles: [dev, test]
Start the app in one of those profiles and the tables fill in the right order, parents before children. Restarts against a database that already has rows skip seeding, so nothing is duplicated.
It's tested in CI on Spring Boot 4.1 and 3.5. Spring Boot 3.5 apps still need Java 21 or newer.
What's actually happening underneath
Entity scanning. SynthForge reads JPA-managed attributes through the jakarta.persistence.metamodel.Metamodel API, not raw reflection. That distinction matters more than it sounds: only real persistent fields ever get touched, nothing that merely resembles one.
Relationship ordering. From the owning-side @ManyToOne/@OneToOne relationships, it builds a dependency graph and topologically sorts it. Parent rows exist before a child is ever generated to reference them. This is precisely where hand-written scripts fail first: the moment a second level of nesting shows up, manual ID-tracking becomes its own source of bugs, the thing you're debugging instead of your actual feature.
Constraint-aware generation. @NotNull, @Size, @Email, and a set of field-name heuristics (email, iban, amount, country) drive realistic values through Datafaker. Hit a @Column(unique = true) field, and instead of a constraint violation forty rows in, you get a bounded retry loop.
Idempotent restarts. A table that already has rows gets skipped. Restart against a persistent database as many times as you want; nothing duplicates.
"Isn't this just Instancio?"
Fair question, and worth answering directly rather than dodging it. Instancio, and its instancio-jpa extension, gives you an API to call from inside a test: build a graph, persist it, for that one test. It's genuinely good at that job.
SynthForge isn't called from anywhere. Annotate the entity, start the app in an enabled profile, and the database is already populated before your code runs a single line. Different tool, because it solves a different moment: application startup, not a single test method.
When it isn't the right tool
- You want a fresh object graph inside a single test method. Instancio is built for that.
- You need data in production. Seeding is for dev and test profiles only, and stays off unless you list a profile.
- Your model relies on
@OneToMany/@ManyToManycollections or composite keys. Those fields are skipped in this release.
Where it stands
Live on Maven Central, MIT licensed, at version 0.1.0. Repo: github.com/ThembaTman0/synthforge. Feedback and issues are welcome there.
It won't fix your architecture and it won't make decisions for you. But the next time you add a foreign key at 11pm, there's a decent chance it means you get to go to bed instead.
Top comments (0)