A Spring Boot library that generates HTML forms from JPA entity fields. Each field is a domain wrapper that carries both a value and a list of typed HTML attributes, enabling automatic client-side rendering and server-side validation from the same configuration.
Attributes are first-class records in a sealed Attribute<T> hierarchy — not stringly-typed map entries. Each attribute
owns both its HTML rendering and its own validation logic, so there is no duplication between "how this looks in the
browser" and "how this is checked on the server".
Standard Spring MVC form binding (@ModelAttribute, BindingResult) treats validation and rendering as separate
concerns — @Min(0) on the Java field and min="0" in the Thymeleaf template are duplicated, and any change must be
made in two places.
formmvc collapses this: a single NumberDomain.min(0) call configures both the HTML min attribute the browser
enforces and the server-side validator that rejects out-of-range submissions. The domain field is the single source of
truth for constraints, and a three-method controller subclass is all that is needed to get a fully working CRUD form.
formmvc is published to GitHub Packages.
repositories {
mavenCentral()
maven {
url = uri("https://maven.pkg.github.com/NicolasBFR/FormMVC")
credentials {
username = project.findProperty("gpr.user") ?: System.getenv("GITHUB_ACTOR")
password = project.findProperty("gpr.key") ?: System.getenv("GITHUB_TOKEN")
}
}
}
dependencies {
implementation 'com.nicolasbfr:formmvc:0.0.1-SNAPSHOT'
}<repositories>
<repository>
<id>github</id>
<url>https://maven.pkg.github.com/NicolasBFR/FormMVC</url>
</repository>
</repositories>
<dependency>
<groupId>com.nicolasbfr</groupId>
<artifactId>formmvc</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>| Requirement | Version |
|---|---|
| Java | 17+ |
| Spring Boot | 4.x |
| Gradle | 8.14+ (if building from source) |
import com.nicolasbfr.formmvc.domains.attributes.AutoCapMode;
@Entity
public class Person extends IDedEntity<Long> {
private final TextDomain firstName = new TextDomain()
.required()
.maxlength(50)
.placeholder("First name")
.autocapitalize(AutoCapMode.WORDS);
private final TextDomain lastName = new TextDomain()
.required()
.maxlength(50);
private final EmailDomain email = new EmailDomain()
.required()
.placeholder("you@example.com");
private final NumberDomain age = new NumberDomain()
.min(0)
.max(150)
.required();
private final DateDomain birthDate = new DateDomain()
.min(LocalDate.of(1920, 1, 1));
private final CheckboxDomain active = new CheckboxDomain();
protected Person() {
}
}public interface PersonRepository extends CrudRepository<Person, Long> {
}@Controller
@RequestMapping("/person")
public class PersonController extends AbstractEntityFormController<Long, Person> {
@Autowired
PersonRepository repo;
@Override
protected String getBasePath() {
return "/person";
}
@Override
protected CrudRepository<Person, Long> getRepository() {
return repo;
}
@Override
protected Person getFactory() {
return new Person();
}
}That's it. The framework provides:
GET /person/create— creates a new Person and redirects to editGET /person/{id}/edit— renders an auto-generated HTML formGET /person/{id}/update— validates and saves form dataGET /person/ids— JSON list of all IDs
form.html and formInput.html are included in the library jar — no template files to create. The controller returns
view name "form", which Thymeleaf resolves from the classpath automatically.
For the Person entity above, the rendered form looks like:
<form action="" method="get">
<div>
<label for="firstName">firstName</label>
<input type="text" name="firstName" id="firstName" value="Alice"
maxlength='50' placeholder='First name' autocapitalize='words'>
</div>
<div>
<label for="age">age</label>
<input type="number" name="age" id="age" value="30"
min='0' max='150' required='required'>
</div>
<div>
<label for="active">active</label>
<input type="checkbox" name="active" id="active">
</div>
<!-- one <div> per IDomain field -->
<input type="submit" formaction="/person/1/update"/>
<input type="reset"/>
</form>Validation errors appear as a <ul> under the relevant field. To customise the layout, copy form.html or
formInput.html into your own src/main/resources/templates/ — Spring Boot's Thymeleaf resolver gives your copies
precedence over the bundled ones.
@Configuration
public class MyAppConfig {
@Bean
public Converter<String, LocalDate> stringToDate() {
return LocalDate::parse;
}
@Bean
public Converter<String, LocalDateTime> stringToDateTime() {
return LocalDateTime::parse;
}
@Bean
public Converter<String, LocalTime> stringToTime() {
return LocalTime::parse;
}
}classDiagram
direction TB
class IDomain~S, U~ {
<<abstract>>
#S value
-List~Attribute~ attributes
+addAttribute(Attribute) U
+required() U
+autocomplete(value) U
+list(datalistId) U
+getType()* String
+getAttrString() String
+getAttributes() Map~String,String~
+validate(fieldName) List~String~
+copyAttributesFrom(source)
+convertToDatabaseColumn(U)* S
+convertToEntityAttribute(S)* U
}
class Attribute~T~ {
<<sealedinterface>>
+name() String
+htmlValue() String
+validate(fieldName, value, errors)
}
class IDedEntity~I~ {
<<MappedSuperclass>>
#I id
+findFields() Map~String, IDomain~
+validateAll() Map~String, List~String~~
-restoreAttributes()
}
class AbstractEntityFormController~S, T~ {
<<abstract>>
+update(id, request, model) String
+edit(id, model) String
+create() ResponseEntity
+ids() List~S~
#getBasePath()* String
#getRepository()* CrudRepository
#getFactory()* T
}
IDomain --> Attribute: holds List of
IDedEntity --> IDomain: discovers via reflection
AbstractEntityFormController --> IDedEntity: CRUD operations
AbstractEntityFormController --> IDomain: converts & validates
class IRangeable~S, U~ {
<<interface>>
+min(S) U
+max(S) U
+step(String) U
}
class IPattern~U~ {
<<interface>>
+pattern(String) U
}
class IStringControls~U~ {
<<interface>>
+maxlength(int) U
+minlength(int) U
}
class IPlaceHolder~U~ {
<<interface>>
+placeholder(String) U
}
class IAutoCapitalize~U~ {
<<interface>>
+autocapitalize(AutoCapMode) U
}
IStringControls --|> IPattern: extends
IDomain <|-- TextDomain
IDomain <|-- NumberDomain
IDomain <|-- DateDomain
IDomain <|-- DateTimeDomain
IDomain <|-- TimeDomain
IDomain <|-- RangeDomain
IDomain <|-- CheckboxDomain
IDomain <|-- ColorDomain
IDomain <|-- PasswordDomain
IDomain <|-- TelDomain
IDomain <|-- EmailDomain
IDomain <|-- UrlDomain
TextDomain ..|> IStringControls
TextDomain ..|> IPlaceHolder
TextDomain ..|> IAutoCapitalize
NumberDomain ..|> IRangeable
NumberDomain ..|> IPattern
NumberDomain ..|> IPlaceHolder
DateDomain ..|> IRangeable
DateTimeDomain ..|> IRangeable
TimeDomain ..|> IRangeable
RangeDomain ..|> IRangeable
PasswordDomain ..|> IStringControls
PasswordDomain ..|> IPlaceHolder
TelDomain ..|> IStringControls
TelDomain ..|> IPlaceHolder
EmailDomain ..|> IStringControls
EmailDomain ..|> IPlaceHolder
EmailDomain ..|> IAutoCapitalize
UrlDomain ..|> IStringControls
UrlDomain ..|> IPlaceHolder
UrlDomain ..|> IAutoCapitalize
Every HTML form attribute is a record implementing a sealed interface:
public sealed interface Attribute<T> permits
Required, Autocomplete, Datalist,
Min, Max, Step,
MaxLength, MinLength, Pattern,
Placeholder, AutoCapitalize,
Checked, SwitchMode,
Colorspace, Alpha {
String name(); // HTML attribute name
String htmlValue(); // unescaped value
default void validate(String fieldName, T value, List<String> errors) {
}
}| Attribute | Value type T |
Validates |
|---|---|---|
Required |
Object |
fires on null or empty toString() |
Autocomplete(token) |
Object |
render-only |
Datalist(id) |
Object |
render-only (emits list="...") |
Min<T extends Comparable<? super T>>(bound) |
T |
value.compareTo(bound) < 0 |
Max<T extends Comparable<? super T>>(bound) |
T |
value.compareTo(bound) > 0 |
Step(value) |
Object |
render-only (HTML allows "any" + numeric forms) |
MaxLength(n) |
String |
value.length() > n |
MinLength(n) |
String |
value.length() < n |
Pattern(regex) |
Object |
regex match against value.toString() |
Placeholder(text) |
Object |
render-only |
AutoCapitalize(AutoCapMode) |
Object |
render-only, enum-typed |
Checked |
Object |
render-only, checkbox |
SwitchMode |
Object |
render-only, emits switch="true" |
Colorspace(ColorSpace) |
Object |
render-only, enum-typed |
Alpha(AlphaMode) |
Object |
render-only, enum-typed |
The ? super T bound on Min/Max is deliberate: LocalDate implements Comparable<ChronoLocalDate> (not
Comparable<LocalDate>), and LocalDateTime implements Comparable<ChronoLocalDateTime<?>>. The looser bound lets
both types flow through IRangeable without adapters.
Adding a new HTML attribute is a single file change: add a record to Attribute.java and list it in the permits
clause. The sealed hierarchy makes the catalogue exhaustive at compile time.
sequenceDiagram
participant Browser
participant Controller as AbstractEntityFormController
participant Entity as IDedEntity (Person)
participant Domain as IDomain (TextDomain, etc.)
participant Template as Thymeleaf (form.html)
participant DB as Database
Browser ->> Controller: GET /{id}/edit
Controller ->> DB: findById(id)
DB -->> Controller: entity (values only)
Note over Entity: @PostLoad restoreAttributes()<br/>shallow-copies the attribute list<br/>from a fresh instance back onto<br/>the JPA-loaded domains
Controller ->> Template: model(entity, basePath)
Template ->> Entity: entity.findFields()
Entity -->> Template: Map<fieldName, IDomain>
loop each field
Template ->> Domain: getType(), getAttrString(), getValue()
Domain -->> Template: "number", "min='0',max='100'", "42"
end
Template -->> Browser: HTML with <input type="number" min="0" max="100" value="42">
getAttrString() walks the attribute list, joins name='value' pairs with commas, and escapes apostrophes, commas,
and backslashes in each value so they cannot break Thymeleaf's th:attr="__${attrs}__" preprocessor. A placeholder
like "O'Brien" renders correctly; previously it silently truncated the attribute list.
sequenceDiagram
participant Browser
participant Controller as AbstractEntityFormController
participant Entity as IDedEntity
participant Domain as IDomain
participant Attr as Attribute
participant DB as Database
Browser ->> Controller: GET /{id}/update?name=Alice&age=-5
Controller ->> DB: findById(id)
DB -->> Controller: entity
loop each request parameter
Controller ->> Controller: ReflectionUtils.findField(name)
Controller ->> Controller: ConversionService.convert(value, targetType)
Controller ->> Domain: setValue(converted)
end
Note over Controller: Handle unchecked checkboxes:<br/>set absent CheckboxDomain fields to false
Controller ->> Entity: validateAll()
loop each IDomain field
Entity ->> Domain: validate(fieldName)
loop each Attribute in the list
Domain ->> Attr: validate(fieldName, value, errors)
Attr -->> Domain: appends error if any<br/>(e.g. "age must be at least 0")
end
Domain -->> Entity: errors
end
Entity -->> Controller: Map<fieldName, List<errors>>
alt errors present
Controller -->> Browser: render form.html with errors
else valid
Controller ->> DB: save(entity)
Controller -->> Browser: redirect /{id}/edit
end
IDomain.validate(...) dispatches Attribute.Required unconditionally (so it can fire on null) and skips every other
attribute when the value is null. No string parsing, no instanceof ladders, no per-domain overrides.
Each domain maps a Java type to an HTML <input type> and implements the constraint interfaces relevant to that input
type.
| Domain | Java Type | HTML Type | Interfaces |
|---|---|---|---|
TextDomain |
String |
text |
IStringControls, IPlaceHolder, IAutoCapitalize |
PasswordDomain |
String |
password |
IStringControls, IPlaceHolder |
TelDomain |
String |
tel |
IStringControls, IPlaceHolder |
EmailDomain |
String |
email |
IStringControls, IPlaceHolder, IAutoCapitalize |
UrlDomain |
String |
url |
IStringControls, IPlaceHolder, IAutoCapitalize |
NumberDomain |
Integer |
number |
IRangeable, IPattern, IPlaceHolder |
RangeDomain |
Integer |
range |
IRangeable |
DateDomain |
LocalDate |
date |
IRangeable |
DateTimeDomain |
LocalDateTime |
datetime-local |
IRangeable |
TimeDomain |
LocalTime |
time |
IRangeable |
CheckboxDomain |
Boolean |
checkbox |
(checkbox-specific: checked(), switchMode()) |
ColorDomain |
String |
color |
(color-specific: colorspace(ColorSpace), alpha(AlphaMode)) |
| Interface | Methods | Used by |
|---|---|---|
IRangeable<S extends Serializable & Comparable<? super S>, U> |
min(S), max(S), step(String) |
Number, Range, Date, DateTime, Time |
IPattern<U> |
pattern(String) |
Number (standalone) |
IStringControls<U extends IDomain<String, U>> |
extends IPattern + maxlength(int), minlength(int) |
Text, Password, Tel, Email, Url |
IPlaceHolder<U> |
placeholder(String) |
Number, Text, Password, Tel, Email, Url |
IAutoCapitalize<U> |
autocapitalize(AutoCapMode) |
Text, Email, Url |
IRangeabletightensSwithComparable<? super S>so theMin/Maxrecords can callcompareTodirectly with no string round-trip.IStringControlsis bound toIDomain<String, U>somaxlength/minlengthcan only attach to string-valued domains — a type error the old map-based API could not catch.IAutoCapitalize.autocapitalize(...)takes anAutoCapModeenum (NONE,SENTENCES,WORDS,CHARACTERS) rather than an arbitraryString.
All domains also inherit universal setters from IDomain: required(), autocomplete(String), list(String).
Domain attributes (min, max, required, etc.) are not stored in the database. They are configured at field-initialisation time:
private final NumberDomain age = new NumberDomain().min(0).max(150);
// ^^^^^^^^^^^^^^^^ transient configWhen JPA loads an entity from the database, the @Converter creates a new domain with the value but without attributes.
The @PostLoad hook in IDedEntity fixes this by instantiating a fresh entity (which runs the field initialisers) and
copying the attribute list back. Records are immutable, so a shallow list copy is safe — no defensive cloning required.
sequenceDiagram
participant JPA
participant Entity as Person
participant Fresh as Person (template)
participant Domain as NumberDomain (age)
JPA ->> Entity: load from DB (age.value = 42, age.attributes = [])
JPA ->> Entity: @PostLoad restoreAttributes()
Entity ->> Fresh: new Person() via reflection
Note over Fresh: Field initialisers run:<br/>age = new NumberDomain().min(0).max(150)<br/>age.attributes = [Min(0), Max(150)]
Entity ->> Domain: copyAttributesFrom(fresh.age)
Note over Domain: age.value = 42<br/>age.attributes = [Min(0), Max(150)]
com.nicolasbfr.formmvc
├── AbstractEntityFormController # Base controller with CRUD endpoints
├── IDedEntity # JPA base class with field discovery + validation
└── domains
├── IDomain # Base domain: value + attribute list + validation
├── TextDomain # <input type="text">
├── NumberDomain # <input type="number">
├── DateDomain # <input type="date">
├── DateTimeDomain # <input type="datetime-local">
├── TimeDomain # <input type="time">
├── RangeDomain # <input type="range">
├── CheckboxDomain # <input type="checkbox">
├── ColorDomain # <input type="color">
├── PasswordDomain # <input type="password">
├── TelDomain # <input type="tel">
├── EmailDomain # <input type="email">
├── UrlDomain # <input type="url">
├── attributes
│ ├── Attribute # Sealed interface + 15 nested records
│ ├── AutoCapMode # NONE, SENTENCES, WORDS, CHARACTERS
│ ├── ColorSpace # SRGB, DISPLAY_P3
│ └── AlphaMode # HIDE, SHOW
└── constraints
├── IRangeable # min, max, step (Comparable<? super S> bound)
├── IPattern # pattern (regex)
├── IStringControls # extends IPattern + maxlength, minlength (String-bound)
├── IPlaceHolder # placeholder
└── IAutoCapitalize # autocapitalize (AutoCapMode)