Quickstart
You write no code. First generate a runnable project from the generator — H2 in-memory database, schema script, and complete DDD sample code all included. Your only job is mvn spring-boot:run, then watch it work in the browser.
Once it runs, come back to Core Concepts and Write a Command — it will go much faster.
Step 1: Generate the project
Open https://start.soda.hibuka.com, pick a template, fill in three fields, download the zip.
| Field | What it is | Example |
|---|---|---|
| Project name | Maven's artifactId | my-soda-project |
| Package name | The Java package all code lives under | com.example.soda |
| Spring Boot version | 4.0 or 3.2 — match your stack | 4.0 |
Pick the SODA Simple template (monolith). Choose scoda-cloud if you need a multi-module layout.
The generated project already contains:
- A complete DDD layering layout (adapter / application / domain / infrastructure)
- Full CRUD for a “Producer” aggregate: commands, queries, events, repositories
- H2 in-memory database config + the
schema-h2.sqlschema script - MyBatis-Plus, Knife4j (Swagger), and unified exception handling
Step 2: Just run it
unzip my-soda-project.zip
cd my-soda-project
mvn spring-boot:runNo MySQL to install, no Redis to install. When startup completes you'll see:
Tomcat started on port 8080The generated project defaults to “runs out of the box”:
| Setting | Value | Why |
|---|---|---|
| Port | 8080 | application.yaml |
| Profile | dev | active by default |
| Data source | jdbc:h2:mem:testdb | in-memory, in-process |
| Schema | classpath:schema-h2.sql, run automatically | rebuilt on every start |
| Event bus | unset → SimpleEventBus | in-process dispatch, no Redis involved |
H2 is an in-memory database: stop the process and the data is gone. For “getting it running” that's a feature (every start is a clean environment); for “demonstrating persistence” it's a limitation. Switching to MySQL only touches spring.datasource in application-dev.yaml — not a line of Java changes.
Step 3: Call the four endpoints
The sample ships with a set of endpoints covering all four chains, all POST:
| Endpoint | Which chain | Request body |
|---|---|---|
/api/creatProducer | Command (sync) | {"name": "Acme Electronics"} |
/api/creatProducerSync | Command (async) | {"name": "Acme Electronics"} |
/api/getProducer | Query (single) | {"uid": "the uid from the step above"} |
/api/listProducers | Query (paged) | {"pageNumber": 1, "pageSize": 10} |
Create one:
curl -X POST http://localhost:8080/api/creatProducer \
-H 'Content-Type: application/json' \
-d '{"name": "Acme Electronics"}'Response:
{
"code": 200,
"message": "",
"data": {
"uid": "a1b2c3d4-...",
"name": "Acme Electronics"
},
"success": true
}The creat in the endpoint names is missing an e — a historical misspelling in the generator template, frozen into the sample code. Spell your own business endpoints create; this is sample-template flavor, not a framework constraint.
Query it back to confirm it really persisted:
curl -X POST http://localhost:8080/api/listProducers \
-H 'Content-Type: application/json' \
-d '{"pageNumber": 1, "pageSize": 10}'You should see the record you just created in data. This step is the key one: the command chain worked, the row is in the database, and the query chain reads it back — a complete CQRS round trip.
Step 4: Look inside the database
H2 ships a web console at http://localhost:8080/h2-console:
| Field | Value |
|---|---|
| JDBC URL | jdbc:h2:mem:testdb |
| User Name | sa |
| Password | leave empty |
Click Connect and you'll see two tables on the left: scoda_producer and scoda_consumer. Run:
SELECT uid, name, gmt_create FROM scoda_producer;The scoda_ table prefix is a template convention, not a framework rule. schema-h2.sql and the ScodaProducer entity mapping are consistent — if you rename the tables, change both.
Step 5: Read the code — how one command completes the journey
This is the real point of this page. The sample code is not decoration; it plays through every soda chain once. Read the table below top to bottom and you'll understand the full lifecycle of a command.
| Order | File | Role in the chain |
|---|---|---|
| 1 | adapter/inbound/web/SampleController.java | HTTP entry point; turns the request body straight into a Command |
| 2 | application/sample/service/impl/SampleAppServiceImpl.java | Application service; hands it off via BusFacade |
| 3 | bus/facade/BusFacade (framework) | Puts the command on the bus and finds the CommandHandler |
| 4 | application/cqrs/handler/command/CreateProducerCommandHandler.java | Orchestration: validate → assemble the aggregate → save via repository |
| 5 | domain/producer/Producer.java | Aggregate root; changes state + suspends the domain event |
| 6 | domain/producer/repository/ProducerWriteRepository.java | Write repository interface (the domain layer depends only on interfaces) |
| 7 | infrastructure/repositoryImpl/producer/ProducerWriteRepositoryImpl.java | Implementation, backed by MyBatis-Plus |
| 8 | domain/producer/event/ProducerEvent.java | Domain event, produced by the aggregate root |
| 9 | application/cqrs/handler/evnet/ProducerEventHandler.java | Event consumer, invoked automatically |
Three places worth pausing on
1. The event is raised by the aggregate root, not sent by a service
This line in Producer.create():
addPendingEvent(new ProducerEvent(ProducerEventTypeEnum.CREATE_PRODUCER.getDesc(), this));addPendingEvent is protected — only the aggregate root itself can call it. A service layer trying to fire an event behind the aggregate's back won't even compile. This is the hard guarantee that “events must originate from a domain state change”.
At this point the event is only suspended, not yet published. When does it go out? After the repository save(). The RepositoryEventAspect aspect takes the suspended events and dispatches them after a successful save (after commit, if there is a transaction).
2. The domain layer doesn't know MyBatis-Plus exists
domain/producer/repository/ProducerWriteRepository.java is a pure interface extending WriteRepository<Producer>:
public interface ProducerWriteRepository extends WriteRepository<Producer> {
}WriteRepository fixes four methods: save / update / delete / operate. The CommandHandler injects this interface, not ProducerWriteRepositoryImpl.
The implementation lives in the infrastructure layer, persisting with MyBatis-Plus. Swap in JPA, Redis, or an in-memory Map — the domain layer doesn't change.
Repository type names must contain Repository. The event-publishing pointcut is *..*Repository+, matching “types whose name ends with Repository, plus their subtypes”. With the interface named ProducerWriteRepository, the implementation can be named anything and still gets intercepted; but rename the interface to ProducerStore and the implementation to ProducerStoreImpl and the aspect no longer sees them — events silently stop being published, no error, just gone.
3. Handlers need no registration
ProducerEventHandler carries @Component, and that's it — no subscribe, no registry, no XML. At startup the framework scans all EventHandlers, resolves the event type each one handles from its generics, and builds an index. The moment ProducerEvent is published, it is invoked automatically.
What the layered layout looks like
src/main/java/com/example/soda/
├── adapter/inbound/web/ # entry: controllers, DTOs, assemblers, exception handling
├── application/ # application layer: Command / Query / Handler / app services
│ ├── cqrs/command/
│ ├── cqrs/query/
│ ├── cqrs/handler/command/
│ ├── cqrs/handler/query/
│ └── cqrs/handler/evnet/ # event consumers (this is the template's spelling)
├── domain/ # domain layer: aggregates, events, repository interfaces
│ ├── producer/
│ ├── consumer/
│ └── enums/
└── infrastructure/ # infrastructure: DOs, mappers, repository impls, external clients
├── acl/mysql/
├── acl/redis/
├── repositoryImpl/
└── assembler/There is only one dependency direction: adapter → application → domain, with infrastructure implementing the domain's interfaces from the other side. No Spring / MyBatis / Redis import ever appears in the domain package.
Step 6: Change one line and watch it move
Add a log line inside ProducerEventHandler.handle():
@Override
public void handle(ProducerEvent event) throws BaseException {
log.info("producer event received: {}, producer: {}", event.getEventType(), event.getProducer().getName());
eventValidate(event);
}Restart and call /api/creatProducer again. The console prints this line — proof that the event traveled from the aggregate root, through the repository, through the bus, to the consumer.
Step 7: Switch to production shape
The sample runs on H2 + an in-process bus. Going to production means swapping two things:
| Swap | Where |
|---|---|
| H2 → MySQL | spring.datasource in application-dev.yaml, plus a schema.sql to create tables |
| In-process events → Redis Streams | soda.event.bus-type: redis + Redis connection settings |
soda:
event:
bus-type: redis
redis:
host: ${SODA_REDIS_HOST}
password: ${SODA_REDIS_PASSWORD}
stream:
idempotency:
enabled: trueAlways write bus-type explicitly. Omitting it works (falls back to the in-process bus) but leaves intent unclear; writing spring creates no EventBus at all and the app dies at startup with NoSuchBeanDefinitionException. Details: Configuration.
What you now have running
Up to this point a complete soda application is in place:
- Aggregate roots guard state changes and raise the events
- Commands run the write chain (sync + async), queries run the read chain (single + paged)
- Events are published automatically on save, and consumers receive them automatically
- Unified response envelopes
Result/PageResult - The domain layer has zero dependency on infrastructure
Next steps:
- Understand why each piece is designed this way → Request Lifecycle
- Build an aggregate root from scratch → Write a Command
- Wire in login users, tenants, requestId → Web Layer
- Deliver events across services → Event Bus & Aspect