Skip to content

NicolasBFR/FormMVC

Repository files navigation

formmvc

Build License Java Spring Boot

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".

Why formmvc?

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.

Installation

formmvc is published to GitHub Packages.

Gradle

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'
}

Maven

<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>

Requirements

Requirement Version
Java 17+
Spring Boot 4.x
Gradle 8.14+ (if building from source)

Quick Start

1. Define an entity

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() {
    }
}

2. Create a repository

public interface PersonRepository extends CrudRepository<Person, Long> {
}

3. Create a controller

@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 edit
  • GET /person/{id}/edit — renders an auto-generated HTML form
  • GET /person/{id}/update — validates and saves form data
  • GET /person/ids — JSON list of all IDs

4. Thymeleaf templates (bundled)

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.

5. Register type converters (in your app config)

@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;
    }
}

Architecture Overview

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
Loading

The Attribute<T> Hierarchy

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.

Form Rendering Flow

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">
Loading

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.

Form Submission & Validation Flow

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
Loading

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.

Domain Reference

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))

Constraint Interfaces

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
  • IRangeable tightens S with Comparable<? super S> so the Min/Max records can call compareTo directly with no string round-trip.
  • IStringControls is bound to IDomain<String, U> so maxlength/minlength can only attach to string-valued domains — a type error the old map-based API could not catch.
  • IAutoCapitalize.autocapitalize(...) takes an AutoCapMode enum (NONE, SENTENCES, WORDS, CHARACTERS) rather than an arbitrary String.

All domains also inherit universal setters from IDomain: required(), autocomplete(String), list(String).

How Attributes Survive JPA

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 config

When 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)]
Loading

Package Structure

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)

About

A Spring Boot library that generates HTML forms from JPA entity fields.

Resources

License

Contributing

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors