Home > Blog > How the Visitor Pattern Works in a Real Java API

How the Visitor Pattern Works in a Real Java API

September 28, 2026 — 7 min read

This blog shows how a Java Visitor fills in missing data from several systems, instead of putting all of that logic in one service.

The Individual Profile API on Qiwa stores a person’s professional profile, including their jobs (work history) from four verified systems and from what they type in themselves.

Java developers learn design patterns at university. At work, Factory, Singleton, and Strategy show up. The Visitor pattern usually does not. A typical Spring Boot CRUD service has no reason to use it.

This API is not typical CRUD. Enrichment fills lookups, dates, occupation, and location. Validation checks the same fields on write.

This blog explores the problem, explains the Visitor contract in code, breaks down each visitor’s role, and shows how the service executes them. It also covers when the Visitor pattern is worth the added complexity and when a simple mapper is the better choice.

What Work History Looks Like in This API

When someone opens Work history in the app, the API does not read one table. It loads jobs we store ourselves, calls the government systems(shows in the diagram), then merges the two lists and sorts them by start date.

GetWorkHistory: unverified jobs from the database, verified jobs from GOSI, CM, and MCS

After that merge, every job looks the same on the API, whether it came from GOSI (social insurance), MCS (civil service), CM (Qiwa contracts), or from our own database: company, position, dates, status, occupation, location. The codes behind those fields are not shared.

A GOSI occupation id is not an MCS job title. A GOSI city is not an MCS city. Some GOSI statuses are literals, so they must be returned to the client exactly as GOSI provides them.

If that mapping lives in Work History Service, the class fills with “if (gosi) else if (mcs)” on every field. Enrichment on read and validation on write land in the same pile. A new source means opening that method again. That is the part the Visitor is for.

The Visitor Pattern, Without the Textbook

The version in Design Patterns is the one everyone learns. An object has accept(visitor). The visitor has a visit method per type. The work sits in the visitor, not in the model.

This API does not really visit types like Gosi Work History versus Mcs Work History. It walks the fields of one model: start date, company, occupation, extra location data, and the rest.

A visitor only implements the fields it needs. The rest are empty Java default methods, so those fields are skipped.

It does not follow the classic double-dispatch approach. Instead, the visitor simply moves through the object structure while keeping the actual processing logic outside the model. The model only provides the structure the visitor needs to work with.

How the Contract Looks in Code

WorkHistoryModel has one accept method. It passes itself to the visitor:

public <T extends WorkHistoryModel> void accept(WorkHistoryVisitor<T> visitor) {
    visitor.accept((T) this);
}

The walk itself lives on the interface. The order is fixed. First comes init, for things you want to load once, like all contact references for that person. Then field by field.

The full WorkHistoryVisitor is in this WorkHistoryVisitor Gist.

If you add a field on WorkHistoryModel, it has to go into that walk too. Otherwise no enricher will see it. That is the point. One place lists the parts of the record.

A new visitor is a new class. Small enrichment changes should not land in WorkHistoryService.

What the Visitors Do Here

On write, one visitor validates the payload. Occupation has to exist in the lookup. City, region, and country have to exist and match. If the job is not active, a reason for leaving is required.

The API stores Gregorian and Hijri dates. A shared enricher fills whichever calendar is missing, and marks the job as completed when the end date is in the past. Every source needs that, so it sits in the base class.

Country, region, and city are filled by one visitor through lookups. Occupation is two visitors on the same field: one list first, then our own lookup if that field is still empty.

Contact references are another visitor. It loads them once, then attaches them to each record. Without that, a list GET would hit the database once per row.

Each source still has its own enricher. They share a base class and only override the fields that are special to that source.

On a full read, the visitors run in order: the source enricher, then location, then occupation.

That order matters. The source enricher is the only one that knows how GOSI, MCS, or CM names a status or an occupation code, so it runs before the shared lookups. The second occupation visitor only fills the field when the first one left it empty, so a code that’s already mapped is not replaced.

Validation walks the same fields, but it does not write anything back. It stops on the first failure, and the service returns that error before the record is saved. A missing lookup is a validation error, not a silent empty field.

How the Service Runs the Visitors

Not every call needs all of that. If you only want to know whether the person has any jobs, there is no point loading lookups and attachments. That is why there is a fetch without enriching, and a second pass for the rest.

A rough picture of GET:

List<WorkHistoryModel> result = getWorkHistoryWithoutEnriching(...);
enrichVerifiedWorkHistory(/* GOSI, MCS, CM, CMTR only */);
result.forEach(wh -> wh.accept(locationDataEnricher));
result.stream()
    .filter(GosiWorkHistoryModel.class::isInstance)
    .map(GosiWorkHistoryModel.class::cast)
    .forEach(wh -> wh.accept(gosiOccupationEnricher));
result.forEach(wh -> wh.accept(occupationEnricher));

Which enricher runs depends on the type:

if (model instanceof GosiWorkHistoryModel) {
    model.accept(gosiDataEnricher);
} else if (model instanceof CmWorkHistoryModel) {
    model.accept(cmDataEnricher);
} else if (model instanceof CmTrainingWorkHistoryModel) {
    model.accept(cmTrainingsDataEnricher);
} else {
    model.accept(mcsDataEnricher);
}

There is still a type check at the boundary. The Visitor does not eliminate every switch, it simply moves that decision to the beginning. Once the correct visitor is selected, the rest of the mapping is handled field by field without additional branching.

When the data is saved, the steps are straightforward: validate first, persist the changes, then handle location and occupation mapping. The visitor keeps some state while processing a request, so it is created as a new instance for each request rather than shared as a singleton. This keeps data from one person’s processing separate from another’s.

When This Pattern Is Worth It

This pays off when one model has more than one source, you run more than one pass over it, and each pass only cares about some of the fields. Lookups do not belong in getters on the model.

For a normal CRUD API it is too much. A mapper or a simple switch is enough. This was not that. Several external APIs, two calendars, occupation codes, and extra data the user adds all sit on the same object.

The walk is written once. Each source brings its own visitor. An empty visitor in a test is enough to check that every field is visited. A new enricher can be tested without the whole service.

Conclusion

The Visitor pattern is rare in Spring Boot because most APIs never need it. Here it earns the extra types: one walk over the record, and the rules for each source stay in their own class.


Sources

Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides, Design Patterns: Elements of Reusable Object-Oriented Software, Addison-Wesley, 1994. Visitor pattern (Wikipedia)

Oracle, Default Methods. Spring Framework, Bean Scopes: Prototype.

Tin Rupcic
Tin Rupcic

Tin Rupčić is a Java Engineer at Q, where he works on backend services, currently focusing on the Individual Profile API. Most of his work revolves around Java, backend development, and making sure things work as expected. Outside of work, he enjoys sports, programming, robotics, and anything that involves building something just to see if he can. He also has a soft spot for food, which is probably the only thing he enjoys more than debugging code.

GIVE KUDOS BY SHARING THE POST!

Partner with us