So one thing that's been happening in my professional life in recent times is that I've been doing relatively modern Java with a team that has been kind of I dunno, FP-curious I guess? Like not super familiar with all the things, but generally open to the idea having more methods return stuff and fewer methods perform stuff. Relatively modern Java is less bad than relatively old Java at that stuff and overall I think it's been a pretty good experience.
This stuff also takes place in the domainy enterprise software engineering context, for what's that worth. Anyway here's a kind of thing that has happened some times:
An example. Let's say a person is this in some domain:
Or:
record Person(
String firstName,
Optional<String> middleName,
String lastName,
LocalDate dateOfBirth,
Optional<LocalDate> dateOfDeath,
Optional<String> idNumber) { ... }One thing that sometimes happen is that people start making "wither" methods for the records:
public Person withFirstName(String newFirstName) {
return new Person(
newFirstName,
middleName,
lastName,
dateOfBirth,
dateOfDeath,
idNumber);
}And so on. This has come up as an annoyance. Some times I have been kind of apologetic and been like "yeah there's a JEP for that but for now I suggest we just muddle through and hope that things get a little better as we get more of a handle on things and discover which fields should be extracted into their own records and stuff like that."
Extracting fields that belong together has often worked out:
record Person(Name name,Life life, Optional<IDNumber> id) { ... }
record Name(String first, Optional<String> middle, String last) { ... }
sealed interface Life {
record Alive(LocalDate born) implements Life { ... }
record Dead(LocalDate born, LocalDate died) implements Life { ... }
}
record IDNumber(String number) { ... }Or something along those lines.
(For two datastructurally very similar things, we might choose to model one of them with a sealed interface with records and the other with a record with an optional field, usually depending on like how the difference between presence and absence is meaningful in the domain.)
So we get fewer fields per record, so fewer withers per record and fewer arguments per wither, and the bodies of the withers become oneliners:
Person withName(Name newName) {
return new Person(newName, life, idNumber);
}And so on.
Anyway that's one thing. Another is that we end up mostly not wanting the withers anyway. We mostly want methods that correspond to state transitions that make sense and are "valid" in the domain. We might end up with something more like this:
record Person(Name name, Life life, Optional<IDNumber> id) {
static Person citizen(Name name, LocalDate born, IDNumber id) {
new Person(name, new Life.Alive(born), Optional.of(id));
}
static Person noncitizen(Name name, LocalDate born) {
new Person(name, new Life.Alive(born), Optional.empty());
}
Person changeName(Name newName) {
return new Person(newName, life, idNumber);
}
Person dead(LocalDate died) {
return new Person(name, life.end(died), idNumber);
}
Person gainCitizenship(IDNumber newId) {
id.ifPresent(x ->
throw new IllegalStateException("can't change existing ID number"));
id = newId;
}
...
}
record Name(String first, Optional<String> middle, String last) { ... }
sealed interface Life {
record Alive(LocalDate born) implements Life {
Life end(LocalDate died) {
return new Dead(born, died);
}
}
record Dead(LocalDate born, LocalDate died) implements Life {
Life end(LocalDate died) {
throw new IllegalStateException("I'm already dead! >:(");
}
}
...
}
record IDNumber(String number) {}You know, assuming things kind of work like that in this domain. We could imagine methods like that corresponding to things that the system we're working on is really actually concerned with.
And I dunno, at that point I'm not really wishing for derived record creation syntax. The objects tend to have fewer fields and that but we also just don't have this one-to-one correspondence between fields and withers. Or really: We don't have withers but like proper domainy methods instead (sometimes accidentally functionally equivalent to withers).
Extracting all the "name" fields into one record and the birth and death fields into another might seem obvious. A domainy domain has things in it that are murkier and less familiar when starting out, but you know.
Also I think this is pretty OOP. Kind of DDD blue book value object stuff. I also think stuff like "if you have the same prefix/suffix in several field names maybe make a separate object for those" is common OOP advice. I mean withers are basically setters and "getters and setters blah blah data structure," as the saying goes.
I think having shallowly immutable types easily available in the form of records has been useful here. At least in my team it has been pretty easy to establish them as the default when we're talking about decomposition and extracting fields into new objects. And then by default decomposition never introduce new mutable things and new shared mutable state problems into the world. It's very safe and kind of nice. (I think decomposition like this sometimes goes sideways or gets awkward just because mutability is such a default in a lot of code.)
I think the point kind of is: Doing stuff differently can bring up some concerns/annoyances/inconveniences and it can do that pretty early on. Those things can be real problems but it can also turn out that they mostly go away if you just stick with the different approach for a while and kind of go all the way with it, or at least you like further. I've seen this with a bunch of data structure stuff like this. (I've also seen similar stuff happen with very different kinds changes. Like with processy how-we-work-as-a-team stuff, like letting go of PR/MRs.)
Like uh people are used to doing things certain ways. Maybe those ways are kind of locally optimal or maybe just locally okay and tolerable enough or something. Moving out of that brings with it stuff: All the related things don't change at the same instant, some things that used to be convenient and unproblematic become problematic maybe because we're not changing this other stuff. And it might not be clear, or it might be a matter of perspective, if making the change caused a problem or if not following through and making other related changes causes the problem. And it might not be that clear what things are related and how. And so on and so forth and what have you.
At least I think there's a real risk of e.g. concluding early that this causes too much inconvenience, and that concluding later is sometimes useful.