In applications with complex forms, users often need to save their progress to return later or hand the work over to someone else. These drafts are therefore incomplete and contain empty fields or partially filled objects. Representing this in a domain model is challenging, as the domain typically requires valid, complete data for the process that follows submission. This post explores different approaches to modeling such drafts.

TLDR: Drafts and final domain objects serve different purposes and should be modeled separately. Drafts handle partial, flexible data (often stored as JSON), while final models enforce validation. This split improves maintainability and reduces errors.

Example

Consider an application for a grant that needs to be filled out. The overall process requires the applicant to provide a lot of information, much of which they might need to look up along the way. The need for a draft arises because gathering this information can take time. In this example, the applicant needs to provide the following:

  • Personal information
    • Name
    • Birthdate
    • Address
  • Relationship information (same fields as personal information)
    • Parents
    • Partner
  • Financial information
    • Transaction history
    • Estate
  • Educational information
    • University
    • Degree
    • Title
  • Application purpose
    • Amount
    • Details

This seems simple enough to model: you have a top-level application, and your model might look approximately like this:

classDiagram class Application { +PersonalInformation personalInfo +List~RelationshipInformation~ relationships +FinancialInformation financialInfo +EducationalInformation educationInfo +ApplicationPurpose purpose } class PersonalInformation { +String name +Date birthdate +String address } class RelationshipInformation { +PersonalInformation person +String relationType } class FinancialInformation { +List~Transaction~ transactionHistory +String estate } class Transaction { +Date date +Double amount +String description } class EducationalInformation { +String university +String degree +String title } class ApplicationPurpose { +Double amount +String details } Application --> PersonalInformation Application --> FinancialInformation Application --> EducationalInformation Application --> ApplicationPurpose Application --> "0..*" RelationshipInformation RelationshipInformation --> PersonalInformation FinancialInformation --> "0..*" Transaction

However, there are major drawbacks with this approach:

  • The form may not align with the domain model. One page in the form might populate parts of both PersonalInformation and EducationalInformation.
    • For example, the user might enter their title on the first page.
  • Each field would need to be nullable to support saving partial input.
    • For instance, the user might fill out their name and address but leave birthdate empty, planning to fill it in later.
  • If the domain model mirrors the UI layout too closely, changing the form structure becomes a major maintenance burden. For example, modifying a single field might require large changes in your database schema.

Given this, modelling drafts is not as straightforward as it initially seems. The model above doesn’t support partial input without making every field nullable.

Supporting Partial Input

To support partially filled-out forms, every field would need to be nullable. For example, the transactionHistory list might contain a Transaction object with all fields set to null, representing a placeholder the user hasn't filled in yet. This could work, especially if you validate the entire structure only upon final submission.

However, using validators this way makes the model harder to work with. You have to account for null values even in contexts where you expect valid data. If your validation logic fails to catch nulls, failures occur deeper in the application flow, making them harder to debug.

This approach tends to cause problems later in development, as working with a nullable model introduces fragility. A more robust alternative is to split the model into two separate representations.

Splitting the Model

A better approach is to define two distinct models:

  • DraftApplication
  • Application
classDiagram class DraftApplication { +Optional~String~ name +Optional~LocalDate~ birthdate +Optional~String~ address +List~DraftRelationship~ relationships +Optional~DraftFinancialInformation~ financialInfo +Optional~DraftEducationalInformation~ educationInfo +Optional~DraftApplicationPurpose~ purpose } class Application { +PersonalInformation personalInfo +List~RelationshipInformation~ relationships +FinancialInformation financialInfo +EducationalInformation educationInfo +ApplicationPurpose purpose } class DraftRelationship class RelationshipInformation class DraftFinancialInformation class FinancialInformation class DraftEducationalInformation class EducationalInformation class DraftApplicationPurpose class ApplicationPurpose DraftApplication --> "0..*" DraftRelationship Application --> "0..*" RelationshipInformation DraftApplication --> DraftFinancialInformation Application --> FinancialInformation DraftApplication --> DraftEducationalInformation Application --> EducationalInformation DraftApplication --> DraftApplicationPurpose Application --> ApplicationPurpose

The DraftApplication is a flexible, nullable structure for storing in-progress data, while the Application is a validated, finalized version that enforces all domain invariants. Constructing an Application from a DraftApplication becomes a clear operation: either the creation succeeds with a valid object, or it fails with validation errors. Jump to the appendix for a full implementation example.

While this approach requires more upfront setup, it avoids many long-term issues. Over time, the final Application model often diverges from the draft, for example by including fields related to evaluations or documentation. Maintaining separate models allows for this evolution without compromising data integrity.

Thus, although sharing a single model might be faster early on, it slows development later. It's generally better to invest in the split from the start.

Representing Drafts in JSON

You can store the draft model as JSON in your database. This avoids duplicating schema changes across both draft and final models. Since draft data is rarely queried directly, this format works well.

However, there are trade-offs:

  • Migrations become harder, as altering JSON fields is more involved than updating structured columns.
  • Lack of validation at the database level can lead to runtime issues. If bad JSON is saved, debugging can be difficult, especially without production access.

For example, a failed migration that corrupts 10% of the drafts could make the application unstable, and identifying the root cause would require inspecting the raw data.

PostgreSQL and other databases support JSON operations, which ease migration and reduce these drawbacks.

If your draft has an Overview or Preview step—perhaps to show an AI-generated assessment before submission, you might choose to store that summary in a structured model while keeping user input in JSON. This makes working with system-generated data easier.

Storing in the Same Relational Table (Not Recommended)

You could store draft and final data in the same relational table, using a flag like draft = true or draft = false. This approach makes it easier to maintain the same relations and schema for both drafts and submitted applications.

However, there are several drawbacks:

  • Loose database guarantees because most fields must be nullable to support partial drafts.
  • More error-prone querying, since every query must correctly filter by the draft flag.
  • Complicated migrations, as migrating a row might require different logic depending on whether it is a draft or a final record.

Therefore this is not recommended.

Conclusion

When working in a domain that requires drafts, it's beneficial to separate the draft model from the final model. While this takes more effort upfront, it pays off as the system evolves.

Storing drafts as JSON is viable, but you must handle validation and migration carefully. Only user-provided input should live in the draft; anything generated by the system should go in the final model.

In summary: prefer splitting your model in two.

Appendix: Going from DraftApplication to Application

The example is a static factory method in the Application:

This example uses a Result<T, E> type inspired by Rust, where T is the valid object and E is a list of validation errors. You can implement this pattern easily in any language.

public static Result<Application, List<String>> fromDraft(DraftApplication draft) {
    List<String> errors = new ArrayList<>();

    // Validate required Optionals for primitive fields
    if (!draft.name.isPresent() || draft.name.get().isBlank())
        errors.add("Name is required.");
    if (!draft.birthdate.isPresent())
        errors.add("Birthdate is required.");
    if (!draft.address.isPresent() || draft.address.get().isBlank())
        errors.add("Address is required.");
    if (!draft.purpose.isPresent()) {
        errors.add("Application purpose is required.");
    } else {
        DraftApplicationPurpose purpose = draft.purpose.get();
        if (purpose.amount == null || purpose.amount <= 0)
            errors.add("Purpose amount must be positive.");
        if (purpose.details == null || purpose.details.isBlank())
            errors.add("Purpose details are required.");
    }

    // Validate and convert relationships, accumulate errors
    List<RelationshipInformation> relationships = new ArrayList<>();
    for (DraftRelationship dr : draft.relationships) {
        Result<RelationshipInformation, List<String>> relResult = RelationshipInformation.fromDraft(dr);
        if (relResult.isFailure()) {
            errors.addAll(relResult.getFailure());
        } else {
            relationships.add(relResult.getSuccess());
        }
    }

    // Validate and convert financialInfo
    FinancialInformation financialInfo = null;
    if (!draft.financialInfo.isPresent()) {
        errors.add("Financial information is required.");
    } else {
        Result<FinancialInformation, List<String>> finResult = FinancialInformation.fromDraft(draft.financialInfo.get());
        if (finResult.isFailure()) {
            errors.addAll(finResult.getFailure());
        } else {
            financialInfo = finResult.getSuccess();
        }
    }

    // Validate and convert educationInfo
    EducationalInformation educationInfo = null;
    if (!draft.educationInfo.isPresent()) {
        errors.add("Educational information is required.");
    } else {
        Result<EducationalInformation, List<String>> eduResult = EducationalInformation.fromDraft(draft.educationInfo.get());
        if (eduResult.isFailure()) {
            errors.addAll(eduResult.getFailure());
        } else {
            educationInfo = eduResult.getSuccess();
        }
    }

    if (!errors.isEmpty()) {
        return Result.failure(errors);
    }

    // Now safe to unwrap Optionals and build final Application
    PersonalInformation personalInfo = new PersonalInformation(
        draft.name.get(),
        draft.birthdate.get(),
        draft.address.get());

    DraftApplicationPurpose draftPurpose = draft.purpose.get();
    ApplicationPurpose purpose = new ApplicationPurpose(
        draftPurpose.amount,
        draftPurpose.details);

    Application application = new Application(
        personalInfo,
        relationships,
        financialInfo,
        educationInfo,
        purpose
    );

    return Result.success(application);
}

The code is very easily unit tested, which is another benefit of this approach.