As a Dev, I need to Refactor, to make the codebase Refactorable. Refactorability, as defined by Martin Fowler is, “The ability to alter internal structure without changing its external behavior”. This applies to each atomic component of an app. Let’s achieve our dev story through a unique, yet the most effective method, Component Isolation.
Let’s huddle over persuasive arguments against Obstacles that hinder our goal causing entanglements among components, and Metrics that measure our success.
Let’s refactor a real-world app and witness how Isolated components make up long-lasting structures.
This applies to software developers at all levels. I use Java to demonstrate the code snippets, but this talk is about Software Design and is agnostic of programming language.
Let’s check out the definition](https://refactoring.com/) from the person who authored an entire book about it.
Refactoring is a disciplined technique for restructuring an existing body of code, altering its internal structure without changing its external behavior
That brings us to the story of our talk.
Let’s follow the MOM process to manage our goal. Believe me, Managers love it when you do talk to them in their language. There are high chances they fund your refactoring in the same release!
We shall only focus on a couple of important Methods to achieve our goal:
Human Readability
Component Isolation
And a couple of major Obstacles that sabotage those:
And the Metrics to keep a check on our goals:
We shall apply those methods to a real-world** application** and realize how its metrics are affected.
Let’s start with Obstacles and how to overcome them:
Let’s see how some of what we talked about fits into a Real-world application
Failure
.Failures
and Egg
), and to handle partial failures, the first default choice was to use a HashMap
, which is passed in and out of each step in the flow.void filterDuplicates(Map<ID, Failure> failureMap,
List<Egg> eggsFromRequest) {...}
void validate(Map<ID, Failure> failureMap,
List<Egg> nonDuplicateEggs) {...}
List<EggEntity> toEntityObjs(List<Egg> validEggs) {...}
void bulkInsertIntoDB(List<EggEntity> eggEntityObjs)
throws DMLOperationException {...}
filterDuplicates(failureMap, eggsFromRequest);
// Partitionvar nonDuplicateEggs = new ArrayList<Egg>();for (Egg egg : eggsFromRequest) { if (!failureMap.containsKey(egg.getId())) { nonDuplicateEggs.add(egg); }}
validate(failureMap, nonDuplicateEggs);
...
// Partition
...
var eggEntityObjs = toEntityObjs(validEggs);
try {
bulkInsertIntoDB(eggEntityObjs);
} catch (DMLOperationException ex) {
handlePartialFailures(failureMap, ex);
}
// Prepare response from `failureMap` and db insertion `results`
This flow has all the problems we discussed before. Let’s apply our methods and see how this can be Refactored into.
We can use Either
to represent the partial failures. This eliminates the need for a temporary data structure like HashMap
, coz now both Valids and Failures can share the same List
// Before
void filterDuplicates(Map<ID, Failure> failureMap,
List<Egg> eggsFromRequest) {...}
// After
static List<Either<Tuple2<ID, Failure>, ImmutableEgg>> filterDuplicates(
final List<ImmutableEgg> eggsFromRequest) {...}
List<Either<Tuple2<ID, Failure>, ImmutableEgg>>
can be visualized.right
to left
state if it fails downstream.Either.
leftare auto-skipped from processing downstream. This is possible due to the **Monad** property of
Either`.static Either<Tuple2<ID, Failure>, ImmutableEgg> validate(
Either<Tuple2<ID, Failure>, ImmutableEgg> eggToValidate) {
return eggToValidate // No validations execute if egg is Either.left
.flatMap(validateAge)
.flatMap(validateField1)
...
...
;
}
Monad is out of scope to be covered here, but I have an entire 1 hour talk about this, which can help you fill in this missing piece:
This is how we refactored these components into isolated pieces.
// void filterDuplicates(Map<ID, Failure> failureMap,
// List<Egg> eggsFromRequest) {...}
static List<Either<Tuple2<ID, Failure>, ImmutableEgg>> filterDuplicates(
final List<ImmutableEgg> eggsFromRequest) {...}
// void validate(Map<ID, Failure> failureMap,
// List<Egg> nonDuplicateEggs) {...}
static Either<Tuple2<ID, Failure>, ImmutableEgg> validate(
final Either<Tuple2<ID, Failure>, ImmutableEgg> egg) {...}
// List<EggEntity> toEntityObjs(List<Egg> validEggs) {...}
static Either<Tuple2<ID, Failure>, EggEntity> toEntityObj(
final Either<Tuple2<ID, Failure>, ImmutableEgg> egg) {...}
// Partition
// void bulkInsertIntoDB(List<EggEntity> eggEntityObjs)
// throws DMLOperationException {...}
static Either<DMLOperationException, List<EggEntity>> bulkInsertIntoDB(
final List<EggEntity> eggEntityObjs) {...}
But most importantly, they fit into each other, resembling a math derivation.
All these isolated pieces elegantly fit together, like Lego, groove-to-groove.
final var partition = filterDuplicates(immutableEggsFromRequest).stream()
.map(EggService::validate)
.map(EggService::toEntityObj)
.collect(Collectors.partitioningBy(Either::isRight));
final var objsToInsert =
partition.get(true).stream().map(Either::get).toList();
final var results = bulkInsertIntoDB(objsToInsert);
// Prepare response from `partition.get(false)` and `results`
How are we doing on our Goal! let’s ask our Metrics:
I don’t need any metrics to say that the refactored code is more readable. But for a few, that might not be the case. I heard some say this is more “Complex”. Complexity is a wide word thrown around loosely. Before labeling something complex, we need to be clear to ourselves, as well as others about what is Complex.
Complexity has different types:
It has Different layers too:
The refactored code may feel complex because:
Either
before.That said Cognitive Complexity can be Objectively measured using tools like SonarQube™.
My previous talks, on the usage of SonarQube™:
If you followed the methods we discussed, you get Testability for free. Well, most of it. There are just few more that you can follow to boost it further.
Take the example of this simple function, which maps the request object to the entity object.
// * EggEntity is a legacy class and cannot be instantiated with `new`
void fillEntityObj(Egg egg, EggEntity eggEntity) {
if (egg.field1() != null) {
eggEntity.put(Fields.field1, egg.field1());
}
if (egg.field2() != null) {
eggEntity.put(Fields.field2, egg.field2());
}
if (egg.field3() != null) {
eggEntity.put(Fields.field3, egg.field3());
}
}
How do you unit test all these branches? Also, because the EggEntity
can’t be instantiated in a unit test context, we tend to write a test like this:
@Test
void fillEntityObjTest() {
// * `EggEntity` Object can't be instantiated in a JUnit context.
final var mockEggEntity = mock(EggEntity.class);
var egg = new Egg("field1Value", null, "field3Value");
fillEntityObj(egg, mockEggEntity);
verify(mockEggEntity).put(Fields.field1,"field1Value");
verify(mockEggEntity, never()).put(Fields.field2, null);
verify(mockEggEntity).put(Fields.field3,"field3Value");
}
This test is Brittle, because:
Unit Test !== Test Internals
Unit Test === E2E test for Unit
.Unit tests don’t mean testing the internals. It’s an E2E test for your Unit/Component.
You should respect the encapsulation of each unit/component you test and operate on an abstraction one level above.
Your test should be like an idiotic bot that fires your function with input and asserts the output. It’s not supposed to know anything else about your component code.
If it’s asking for internals, it gets tightly latched with your component, which tends to fail every time you refactor. Such tests instead of helping, act as a foot-chain for refactoring.
The solution is not to write better tests but make the main code Testable. Let’s see our method closely, you can observe Static mixed with Signal:
- **Static:** 1-1 field mapping between `Egg` to `EggEntity`
- **Signal:** For each field mapping, fill non-null `Egg` fields into `EggEntity` using `put`.
Let’s do the same in code:
<string, function<immutableegg,="" string="">> fieldMapping = Map.of(
Fields.field1, ImmutableEgg::field1,
Fields.field2, ImmutableEgg::field2,
Fields.field3, ImmutableEgg::field3);</string,>
// Call with Static data and Function reference fillEntityObj(egg, fieldMapping, EggEntity::put);
// Signal static void fillEntityObj( ImmutableEgg egg, Map
<string, function<immutableegg,="" string="">> fieldMapping
BiConsumer<string, string=""> filler) {
fieldMapping.forEach((fieldId, valueGetter) -> {
final var fieldValue = valueGetter.apply(egg); if (fieldValue != null) {
filler.accept(fieldId, fieldValue);
}
});
}</string,></string,>
HashMap::put
to test this function.Unit tests hate side-effects. All your objects used to perform side-effects should be declared as constructor dependencies or method params, so you can stub them for unit tests. Something like this:
@Autowired
public EggService(
...,
@Qualifier(EGG_REPO) EggRepo eggRepo,
...
)
But if all you need is a function from a Dependency, resist injecting the entire object. Instead, inject only the function that you need.
@Autowired
public EggService(
...,
// @Qualifier(EGG_REPO) // EggRepo eggRepo,
@Qualifier(EGG_INSERTER) Function<EggEntity, ID> dbInserter,
...
)
// Main Config
@Bean(EGG_INSERTER)
public Function<EggEntity, ID> insert(
@Qualifier(EGG_REPO) EggRepo eggRepo) {
return eggRepo::insert;
}
// Test Config
@Bean(EGG_INSERTER)
public Function<EggEntity, ID> insertNoOp() {
return eggEntity -> new ID(1); //stub
}
This helps in easy stubbing of this dependency without the need for a mock, and we get the same benefits that we discussed in Separate Static from Signal
Notice, I use the word Testability over Test Coverage. Testability indicates how precisely you can hit a component without the need to mock a lot of things or know its internals. Test coverage is just a metric. It’s very much possible to achieve 100% Test coverage without your code being testable. Such synthetic coverage of course is useless.
Tests should not be an afterthought and should happen parallel to Refactor, like ping-pong.
Even bad code can function. But if the code isn’t clean, it can bring a development organization to its knees - Uncle Bob, Clean code
🇩🇪 WeAreDevelopers World Congress, 2022, Berlin, Germany.
🇪🇺 jLove, 2021, Europe.