I've been working on a new project called FluentSchema and would love to get your feedback on the idea.
It's trying to solve a common problem: we often define the same data structure in three different places:
- The Java
record
(our DTO) - The Jakarta Validation (
@NotNull
,@Size
,@Email
...) - The OpenAPI
.yaml
file (for API docs)
All three have to be kept in sync manually. A change in one place means you have to remember to update the other two. It's a lot of boilerplate and easy to make mistakes.
My library tries to fix this by creating a single source of truth, inspired by libraries like Zod in the TypeScript world.
The whole idea is: Define your schema once, in code, and get everything else derived from it.
Here's the "Before" (The typical way)
// 1. The DTO
public record UserRegistration(
@NotNull @Email String email,
@NotNull @Size(min = 8, max = 100) String password,
@NotNull @Size(min = 2, max = 50) String firstName,
@NotNull @Size(min = 2, max = 50) String lastName,
@Min(13) @Max(120) Integer age,
@Valid Address address
) {}
public record Address(
@NotNull @Size(min = 1, max = 100) String street,
@NotNull @Size(min = 1, max = 50) String city,
@NotNull @Pattern(regexp = "^[0-9]{5}$") String zipCode
) {}
// 2. ...plus a separate Validator class for custom logic
// (e.g., "password can't contain email username")
// 3. ...plus 30+ lines of OpenAPI YAML to define the schema
And here's the "After" (With FluentSchema)
import static io.fluentschema.Schemas.*;
public class UserSchemas {
@GenerateRecord // This generates the record at compile-time
public static final ObjectSchema<User> USER_REGISTRATION = object()
.field("email", string().email().transform(String::toLowerCase))
.field("password", string().min(8).max(100))
.field("firstName", string().min(2).max(50).transform(String::trim))
.field("lastName", string().min(2).max(50).transform(String::trim))
.field("age", integer().min(13).max(120).optional())
.field("address", object()
.field("street", string().min(1).max(100))
.field("city", string().min(1).max(50))
.field("zipCode", string().regex("^[0-9]{5}$")))
.field("tags", array(string().min(2)).optional())
// Custom validation lives with the schema
.refine(user -> {
String emailUser = user.get("email").toString().split("@")[0];
return !user.get("password").toString().toLowerCase().contains(emailUser);
}, "Password cannot contain email username")
.build();
}
From this single definition, you get:
- Compile-Time Code Gen: The
@GenerateRecord
annotation processor generates theUser
andAddress
records. No runtime reflection. - Runtime Validation: You can just call
USER_REGISTRATION.parse(untrustedInput)
and it validates everything, including the custom.refine()
logic. - Data Transformation: It handles the
.transform(String::toLowerCase)
and type coercions (like a string"25"
to anInteger
) automatically. - (Planned) OpenAPI Export: The next step is to export this definition directly to JSON Schema/OpenAPI, so you can ditch the manual YAML file.
This is a new project and I'm just looking for some honest feedback.
- What do you think of this "schema-as-code" approach?
- Is this a problem you've actually run into?
- Any obvious downsides or pitfalls I'm not seeing?
Here's the GitHub repo with the full README if you want to see more:
https://github.com/jlapugot/fluentschema
Thanks for taking a look!
Top comments (0)