EvoSchema is a code-driven database migration framework for microservices architectures, designed to ensure operational safety during major release rollouts.
It uses a phase-based execution model (Pre-DDL → DML / Script → Assert → Post-DDL) to manage schema and data evolution across one or more datasources, with built-in SQL guardrails, multi-datasource coordination (JTA/XA), and developer-defined rollback strategies.
It focuses on operational safety for major releases: explicit ordering, developer-defined compensation SQL for limited rollback, and SQL guardrails (DML-only, DML+query, query-only) to reduce accidental misuse.
New here? Start with the guided demo: Getting Started With TutorialOrderSyncDemo
Quick links:
- Getting Started With TutorialOrderSyncDemo
- Chinese README
- TutorialOrderSyncDemo.java
- TutorialOrderSyncDemoTest.java
In a microservice architecture, each service usually owns its own database schema. During a major version rollout, several services may need to evolve their schemas and data at roughly the same time.
This creates a hard engineering problem:
- schema changes often need release-level consistency
- databases are independent, so there is no shared cross-service transaction context
- MySQL DDL is generally not rollback-friendly because of implicit commits
EvoSchema does not try to solve this as a global distributed transaction platform.
Instead, it provides a practical engineering model:
- define migration logic in code
- split execution into ordered phases
- support multi-datasource execution
- provide limited rollback through developer-defined compensation SQL
- apply SQL guardrails to reduce accidental misuse
EvoSchema is:
- a non-web Spring Boot application
- a single-process migration executor
- an annotation-driven database evolution framework
- suitable for controlled release pipelines and internal migration runners
EvoSchema is not:
- a cross-microservice orchestration control plane
- a distributed strong-consistency release system
- a full SQL sandbox over every possible JDBC entry point
- a generic Flyway/Liquibase replacement focused on pure SQL files
The framework executes one migration component at a time and follows this phase order:
Pre-DDL -> DML / DBScript -> DMLAssert -> Post-DDL
Use @DBPREDDL for preparatory structural changes.
- typically used for
CREATE,ALTER,RENAME, permission changes, or compatibility preparation - each method returns two SQL strings:
- forward SQL
- compensation SQL
- if a later phase fails, EvoSchema tries to execute compensation SQL in reverse order
Use @DBDML for standard data changes.
- returns a list of SQL statements
- current implementation only allows:
INSERTUPDATEDELETE
REPLACE,MERGE,CALL, and DDL are rejected for this annotation
Use @DBScript for more complex migration logic in Java code.
- receives restricted
JdbcTemplateparameters via@TargetDBTemplate - allows common string-SQL entry points for:
- DML:
INSERT,UPDATE,DELETE,REPLACE,MERGE,CALL - query:
SELECT,SHOW,EXPLAIN,DESCRIBE,DESC
- DML:
- rejects DDL through guarded template validation
Use @DBDMLAssert for release-time data assertions.
- intended for consistency checks after DML / DBScript
- receives query-only
JdbcTemplate - allows:
SELECTSHOWEXPLAINDESCRIBEDESC
- throws an exception when assertion logic fails
Use @DBPOSTDDL for cleanup or final structural changes.
- usually contains irreversible operations such as column cleanup or final shape consolidation
- returns forward SQL only
- does not provide compensation SQL
EvoSchema supports two transaction modes:
- single datasource: local transaction
- multiple datasources: Atomikos JTA/XA transaction
Important limitations:
- rollback support is limited and phase-aware
Pre-DDLrollback depends on developer-provided compensation SQLPost-DDLis not automatically rollbackable- database-native DDL rollback is still constrained by the underlying database
- Java 17
- Spring Boot 3.5.x
- Currently MySQL-only; support for mainstream databases such as PostgreSQL is planned in future releases
- XA support is currently MySQL-only; broader database support will be expanded progressively
Build with Maven:
mvn clean packageThe application loads datasource configuration from:
classpath:${profiles.prefixpath}/db.properties
The default runtime properties are in src/main/resources/application.properties.
Relevant keys:
spring.profiles.active=dev
logging.config=classpath:${profiles.prefixpath}/log4j2.xml
spring.jta.atomikos.properties.max-timeout=3000000
spring.jta.atomikos.properties.default-jta-timeout=3000000Configure datasources in db.properties using this format:
evoschema.datasource.customer.driverClassName=com.mysql.cj.jdbc.Driver
evoschema.datasource.customer.url=jdbc:mysql://127.0.0.1:3306/customer_db?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC
evoschema.datasource.customer.username=root
evoschema.datasource.customer.password=123456
evoschema.datasource.finance.driverClassName=com.mysql.cj.jdbc.Driver
evoschema.datasource.finance.url=jdbc:mysql://127.0.0.1:3306/finance_db?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC
evoschema.datasource.finance.username=root
evoschema.datasource.finance.password=123456For datasource key customer, EvoSchema automatically registers:
customerDataSourcecustomerJdbcTemplate
For datasource key finance, EvoSchema automatically registers:
financeDataSourcefinanceJdbcTemplate
Use DBScriptTemplate.java as the scaffold.
Create a new class and rename it, for example:
package io.github.evoschema.dbscript;
import com.google.common.collect.ImmutableList;
import io.github.evoschema.annotation.DBDML;
import io.github.evoschema.annotation.DBDMLAssert;
import io.github.evoschema.annotation.DBPOSTDDL;
import io.github.evoschema.annotation.DBPREDDL;
import io.github.evoschema.annotation.DBScript;
import io.github.evoschema.annotation.TargetDBTemplate;
import java.util.List;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@Component("release_20260401")
public class Release20260401Migration
{
@DBPREDDL(order = 1, dataSource = "customer")
public List<String> preDDL()
{
return ImmutableList.of(
"ALTER TABLE customer_orders ADD COLUMN archived TINYINT DEFAULT 0;",
"ALTER TABLE customer_orders DROP COLUMN archived;"
);
}
@DBDML(order = 1, dataSource = "customer")
public List<String> dml()
{
return ImmutableList.of(
"UPDATE customer_orders SET archived = 0 WHERE archived IS NULL"
);
}
@DBScript(order = 2)
public void script(
@TargetDBTemplate(dataSource = "customer") JdbcTemplate customerTemplate,
@TargetDBTemplate(dataSource = "finance") JdbcTemplate financeTemplate)
{
Long count = customerTemplate.queryForObject(
"SELECT COUNT(1) FROM customer_orders WHERE archived = 0",
Long.class
);
if (count != null && count > 0) {
financeTemplate.update(
"UPDATE finance_orders SET sync_status = 'READY' WHERE sync_status IS NULL"
);
}
}
@DBDMLAssert(order = 3)
public void dmlAssert(@TargetDBTemplate(dataSource = "customer") JdbcTemplate customerTemplate)
{
Long count = customerTemplate.queryForObject(
"SELECT COUNT(1) FROM customer_orders WHERE archived IS NULL",
Long.class
);
if (count != null && count > 0) {
throw new IllegalStateException("archived column still contains null values");
}
}
@DBPOSTDDL(order = 1, dataSource = "customer")
public List<String> postDDL()
{
return ImmutableList.of(
"ALTER TABLE customer_orders MODIFY COLUMN archived TINYINT NOT NULL DEFAULT 0"
);
}
}The component name is the runtime entry id.
For example:
@Component("release_20260401")Use dataSource = "customer" or inject templates with:
@TargetDBTemplate(dataSource = "customer")The datasource name must match the keys in db.properties.
- phase: before DML
- returns: exactly 2 SQL statements
- purpose: forward DDL + compensation DDL
Example:
@DBPREDDL(order = 1, dataSource = "customer")
public List<String> preDDL()
{
return ImmutableList.of(
"ALTER TABLE customer_orders ADD COLUMN ext_id BIGINT DEFAULT NULL",
"ALTER TABLE customer_orders DROP COLUMN ext_id"
);
}- phase: DML
- returns: list of standard DML SQL
- allowed first keywords:
INSERTUPDATEDELETE
Example:
@DBDML(order = 1, dataSource = "customer")
public List<String> fixData()
{
return ImmutableList.of(
"UPDATE customer_orders SET status = 'READY' WHERE status = 'NEW'"
);
}- phase: DML
- style: Java logic with restricted
JdbcTemplate - use when SQL alone is not expressive enough
Example:
@DBScript(order = 2)
public void sync(
@TargetDBTemplate(dataSource = "customer") JdbcTemplate customerTemplate,
@TargetDBTemplate(dataSource = "finance") JdbcTemplate financeTemplate)
{
List<Long> orderIds = customerTemplate.queryForList(
"SELECT order_id FROM customer_orders WHERE status = 'READY'",
Long.class
);
for (Long orderId : orderIds) {
financeTemplate.update(
"UPDATE finance_orders SET sync_status = 'SYNCED' WHERE order_id = ?",
orderId
);
}
}- phase: after DML / DBScript
- style: query-only assertion logic
- purpose: fail fast when expected data state is not satisfied
Example:
@DBDMLAssert(order = 3)
public void assertResult(@TargetDBTemplate(dataSource = "customer") JdbcTemplate template)
{
Long count = template.queryForObject(
"SELECT COUNT(1) FROM customer_orders WHERE status = 'READY'",
Long.class
);
if (count == null || count == 0L) {
throw new IllegalStateException("no READY records found");
}
}- phase: final structural cleanup
- returns: forward SQL only
Example:
@DBPOSTDDL(order = 1, dataSource = "customer")
public List<String> postDDL()
{
return ImmutableList.of(
"ALTER TABLE customer_orders DROP COLUMN old_status"
);
}Run by component name:
mvn -q -DskipTests compile
mvn -q exec:java -Dexec.args="release_20260401"Or invoke the starter directly:
Starter.main(new String[] { "release_20260401" });If no argument is passed, the entry component defaults to the current date in yyyyMMdd format.
Current SQL restrictions are:
- allowed:
INSERTUPDATEDELETE
- rejected:
REPLACEMERGECALL- DDL
- allowed:
INSERTUPDATEDELETEREPLACEMERGECALLSELECTSHOWEXPLAINDESCRIBEDESC
- rejected:
- DDL
- allowed:
SELECTSHOWEXPLAINDESCRIBEDESC
- rejected:
- DML
- DDL
Note:
- the guard currently focuses on common string-SQL
JdbcTemplateentry points - it is not a full sandbox over every possible JDBC API
For a multi-step change, prefer this pattern:
- use
Pre-DDLto add compatible schema - use
DBDML/DBScriptto backfill or transform data - use
DBDMLAssertto verify correctness - use
Post-DDLto finalize structure
This pattern reduces rollout risk and makes failures easier to understand and handle.
The repository includes both integration tests and unit tests.
Current test coverage focuses on:
- happy-path execution of all migration phases
- rollback of completed
Pre-DDLwhen a laterPre-DDLfails - rollback of DML / DBScript plus compensation of
Pre-DDL - SQL guard behavior
- guarded
JdbcTemplatebehavior - dynamic singleton lifecycle for manually registered datasource beans
Run all tests:
mvn -q testRepresentative test classes:
- TutorialOrderSyncDemoTest
- RollbackOnPreDdlFailureDemoTest
- RollbackOnDmlFailureDemoTest
- SqlStatementGuardTest
- GuardedJdbcTemplateTest
- SpringBeanFactoryTest
- EvoSchema coordinates one migration component per process run
- rollback is limited to what the framework and your compensation SQL can express
Post-DDLis not rollbackable by design
This project is licensed under the MIT License.
See LICENSE for the full text.
- it does not coordinate multiple microservices as a central orchestrator
- it does not guarantee global release atomicity across independent services
Use it when you need:
- code-reviewed database evolution logic
- explicit phase ordering
- controlled multi-datasource migration execution
- release-time assertions
- a pragmatic alternative to fully manual migration playbooks
Recommended contribution checklist:
- keep migration behavior explicit and easy to audit
- add or update tests for new execution paths
- avoid widening SQL permissions without test coverage
- document new datasource or runtime conventions in this README
