Background
We started building our app around 3 years ago. After the first few months of rapid development, the business idea was validated, and we slowly transitioned into a new phase. That meant growing the team, as well as starting to make more mature features. To give you a rough idea of our size, the app is over 600 Vue components, twice as many TS files on top of that, written by half a dozen developers.
Goals
- Making the app easier to maintain in the long run
- Increase the quality by improving the test suite
Taken from experience, the apps usually get more complex with scale. A lot of people with different backgrounds and knowledge levels contribute, features need to interact with more parts of the application, the code gets bigger and harder to maintain. We want to minimize the impact of those on the business, while increasing the quality of software by creating a larger number, more robust test cases
The initial path forward
There were three main milestones that allowed us to work on our final solution.
TypeScript
Introducing strong typing to the codebase was an obvious choice. In retrospect it resulted in fewer type-related errors, and a faster development pace, due to developers being more aware of the data types they were handling. We introduced TypeScript gradually, so it was not a blocker for us to roll out, and to this day, after almost 18 months, we still have some leftovers.
Repository pattern
Our API client was responsible for too many things, so we split it out into repositories, responsible for mediating between the application logic and the API — constructing HTTP requests, and mapping responses to domain objects, among others.
Class components
Leveraging the type system in the components themselves, allowed us to benefit from TS to the fullest in Vue components as well. The previous method used some magic (mapGetters
, mapActions
) that confused our tools and allowed for easily-preventable bugs (eg. mapping a non-existent getter didn’t even cause a warning in our setup). Class components didn’t map getters but instead used strongly-typed store directly.
Extracting business logic out of Vue components
We extracted utility functions and generic helpers from Vue component files from the start, but not so much for business logic. The next step was to include also that: simply moving methods / pure functions so they can be tested in isolation. That was rather a cosmetic than an architectural change, but it allowed us to reduce the testing surface for those units. Being able to mock simple inputs and outputs, instead of mocking store and inspecting the resulting virtual dom reduced the entry threshold for writing tests, and we experienced more of them on a daily basis.
Large testing surface
Small testing surface
Modules
In order to avoid a huge, monolithic and complex application, we split it into modules. This way we can have a lot of isolated pieces, each having limited scope — at the cost of some boilerplate when it comes to communication between modules. The approach is to build modules from the bottom up, so starting with the domain. This way we model only what is necessary for the module, and map it using adapters from the global scope.
Given a task for example, one of the main concepts in our app: there is no simple representation of a task entity in the app. Each module has a subset of behaviours. In a monolithic application, those responsibilities would probably be grouped together, resulting in a God Object antipattern.
On a grand level, not every element of a module is public and accessible outside of a module, so at that scale our application is effectively an order of magnitude smaller, as we can discard all of the private elements.
Dependency injection container
To avoid depending on the global state (including functions / logic), we used the dependency inversion principle to inject data and logic into our classes. Instead of importing modules directly and having those dependencies hardcoded, we use both constructor and argument injection and expect them to be provided from the outside. The dependency injection container is responsible for stitching it together. In tests, we skip the DIC and provide the test doubles manually. We selected tsyringe as our DIC library.
Object oriented programming
We decided to use OOP to achieve:
- Coding against abstractions. This way our units are decoupled from each other and the dependencies can be replaced for them, for example for test purposes.
- Dependency Inversion. It’s a way of easily providing the implementations of abstractions mentioned in the previous point.
- Encapsulation to guard the data we operate on using a public access interface
- Composition so we can extract smaller logic and then combine it into higher order responsibilities
All of those can be achieved using both functional and the object oriented paradigms, but the OOP was more familiar to us. For the curious, here’s how we’d do it using functional approach:
- Abstractions and polymorphism using types
- Dependency inversion via partial application: instead of importing other functions directly (from the global scope), expect them as arguments (increase arity) and partially apply them (decrease arity back) using higher order functions.
- Closure context creates encapsulation: local variables in closures are private
- Composition and decoration are concepts native to FP in the first place
// 1. Start out with a class having a global dependency
class GlobalDependencyApiClient {
items(page: number) {
return (new HttpClient).get(`/items?page=${page}`);
}
}
// 2. Expect the dependency from the outside
class DependencyInversionApiClient {
constructor(private readonly http: HttpClient) {}
items(page: number) {
return this.http.get(`/items?page=${page}`);
}
}
// 3. Extract the logic to a function
function ItemsFetcher(http: HttpClient, page: number) {
return http.get(`/items?page=${page}`);
}
class ApiClient {
constructor(private readonly http: HttpClient) {}
items(page: number) {
return ItemsFetcher(this.http, page);
}
}
// inject as an argument and use partial application to provide the dependency
const FunctionalApiApiClient = ItemsFetcher.bind(null, new HttpClient);
// inject using the constructor
const ObjectOrientedApiClient = new ApiClient(new HttpClient);
Layers
Each module is split into layers with various responsibilities:
- Domain: our framework agnostic business logic. This layer can not depend on any of the upper layers.
- Adapters: reach outside of the domain, the connective tissue. If a domain needs some data or logic from other modules, global scope, etc, we know it needs to go through here. Adapters implement domain interfaces, which are then injected using the next layer.
- Dependency Injection Container: resolvers (basically lazy factories) configure how our objects / dependencies are finally stitched together.
- User Interface: Vue components using the DIC layer to resolve services responsible for business logic.
- Public: a facade available for the outside world, this is the output of a module. A selected group of resolved services, Vue components and module events go here.
We have set up the dependency cruiser to enforce the dependency rules described above.
Code examples
A class using dependency injection
export default class ConnectionIdentifierLabelFactory {
constructor(
private readonly workstations: WorkstationRepository,
private readonly tasks: TaskRepository,
private readonly patients: PatientRepository
) {}
get(to: string): string {
const patient = this.patients.get(to);
if (patient) {
return patient.displayName;
}
const workstation = this.workstations.find(to);
if (workstation) {
return workstation.displayName;
}
const task = this.tasks.find(to);
if (task) {
return task.displayName(this.patients);
}
return phoneNumberFormatter(to);
}
}
Adapters that provide data from outside of the domain
@injectable()
export default class StorePatientAdapter implements PatientRepository {
constructor(
@inject(RootStoreToken)
private readonly store: Store,
private readonly patientFactory: PatientFactory
) {}
async searchQuery(phrase?: string): Promise<Patient[]> {
const { items } = await this.store.dispatch(
FETCH_PATIENTS_BY_QUERY_ACTION,
this.getQueryParams(phrase)
);
// map DTO to entities:
return items.map(this.patientFactory.make);
}
}
Providers configure the dependency injection container
export default class SsoProvider implements DpProvider {
register(container: DependencyContainer): void {
container.register<SsoFlowRepository>(SsoFlowRepositoryToken, SessionStorageSsoFlowRepository);
container.register<SsoFlowInitializer>(SsoFlowInitializer, {
useFactory: (c: DependencyContainer) =>
new SsoFlowInitializer(c.resolve(SsoFlowRepositoryToken), window),
});
}
}
Public API facades resolved from the container
export const authorization = <Authorization>resolve(Authorization);
export type { Authorization };
export { default as BookVisit } from './ui/BookVisitProvider.vue';
Vue components resolve dependencies using the container
export default class TaskTileDisplayReminderBadge extends Vue {
@Prop({ type: Object, required: true })
readonly task: Task;
private readonly reminderFactory: ReminderFactory = resolve(ReminderFactory);
private readonly dateConverter: DateConverter = resolve(DateConverterToken);
get date(): DateInterface {
return {
formatted: this.reminder.getFormattedDate(this.dateConverter),
};
}
private get reminder(): Reminder {
return this.reminderFactory.make(this.task.id, this.task.reminderAt);
}
}
The Mother pattern
export default class ServiceMother {
static getSome(): Service {
return new Service(numberId(), 'consultation online', numberId());
}
static getWithAddressId(addressId: string): Service {
return new Service(numberId(), 'consultation online', addressId);
}
}
Test doubles
export default class StaticSsoFlowRepository implements SsoFlowRepository {
readonly storage = new Map();
readonly tokens: string[] = [];
get(): never {
throw new Error('Method not implemented.');
}
save(token: string, value: SsoFlowState): void {
this.tokens.push(token);
this.storage.set(token, value);
}
}
Problems
Classes with dependencies are harder to test than simple functions
Yes, this is a false dichotomy. Simple functions convert to simple classes without dependencies. Instead, if those functions do have dependencies, they probably depend on the global scope and you either don’t test it in isolation or you need to find your implicit dependencies and mock them, either way, using more complex methods (like jest mocking module imports).
Examples of simple assertion and expectation tests:
describe('LoadMoreEvaluator', () => {
test.each`
numberOfBookedSlots | numberOfFreeSlots | expected
${80} | ${80} | ${true}
${300} | ${80} | ${true}
${30} | ${101} | ${false}
${120} | ${120} | ${false}
`(
'should return $expected if there is $numberOfBookedSlots booked slots and $numberOfFreeSlots free slots',
({ numberOfBookedSlots, numberOfFreeSlots, expected }) => {
const bookingSlots = BookingSlotsMother.createWith(numberOfFreeSlots, numberOfBookedSlots);
const sut = new LoadMoreEvaluator();
expect(sut.shouldLoadMore(bookingSlots)).toBe(expected);
}
);
});
const fileRepository = {
uploadPublicFile: jest.fn(),
};
describe('MediaFileUploader', () => {
beforeEach(() => {
fileRepository.uploadPublicFile.mockClear();
});
test('should not upload empty file', async () => {
const sut = new MediaFileUploader(fileRepository);
await sut.tryUpload(null);
expect(fileRepository.uploadPublicFile).not.toHaveBeenCalled();
});
}
A lot of boilerplate
We do not optimize for less disk space. More lines of simple code trump fewer lines of a more complex one. Our OOP approach produces a lot of simple code, that is easier to reason about. Being able to split our functions / classes into multiple smaller ones shows us that in fact there were a lot of responsibilities encapsulated in them, so now we conform to SRP better.
Having a lot of simple test doubles increases the number of lines of code, but not the complexity.
Tests are less readable
Tests for simple units are as simple as they were before. Tests for more complex units are still complex, but the context setup is easier and more explicit. We also improved the architecture of test cases themselves, so we hide away creating the test context using numerous patterns:
- Factories to create specific services
- Gherkin-like syntax to hide away concrete implementations
- The mother pattern
- Implementing test doubles instead of creating them dynamically using
jest.mock()
- Using custom assertions to have a more concise way of testing outcomes
What follows is an example of a test suite using a lot of dependencies replaced by test doubles, written using a gherkin-like syntax:
describe('ConnectionIdentifierLabelFactory', () => {
// test cases declarations using gherkin syntax:
test('call pstn number', () => {
when_i_call('+48500100100');
then_i_expect_label_to_be('+48 500 100 100');
});
test('call task by id with prefix', () => {
const id = uuid();
given_there_is_a_task(new Task(id, '+48500100100'));
when_i_call(`task:${id}`);
then_i_expect_label_to_be('+48 500 100 100');
});
// scenario context preparation with defaults
// dependencies:
let workstationRepository: WorkstationRepository;
let taskRepository: TaskRepository;
let patientRepository: PatientRepository;
// result:
let label: string;
// reset to some defaults before each scenario:
beforeEach(() => {
label = '';
workstationRepository = new WorkstationRepositoryStub();
taskRepository = new TaskRepositoryStub();
patientRepository = new PatientRepositoryStub();
});
// region steps definitions: givens, whens and thens
function given_there_is_a_task(...tasks: Task[]): void {
taskRepository = new TaskRepositoryStub(tasks);
}
// create system under test with all the prepared dependencies
// and calculate the result for given input
function when_i_call(to: string) {
const sut = new ConnectionIdentifierLabelFactory(
workstationRepository,
taskRepository,
patientRepository
);
label = sut.get(to);
}
// assert that the results match expectations
function then_i_expect_label_to_be(expected: string) {
expect(label).toBe(expected);
}
});
No way forward
Vue 3 does not use class components anymore, we can’t upgrade. This is true, but at the same time, we stray away from Vue specific features, and our view layer is actually thin. The business logic is framework agnostic. Abandoning class components wouldn’t be a huge issue for us, since the core OOP classes are in the domain layer.
Moving to Vue 3 would probably be as easy for us as moving to React. If we ever make that decision, we can still make use of the composition API for local, view-related stuff, and replace the integration layer to communicate with our domain.
Barrier of entry for new developers
At first glance, straying away from the standard way of doing things (just the framework way) increases the barrier of entry for new team members. Yes, it is. But our app would never be just a simple Vue application. It would always be a large, and complex one. It’s best for us to spread this complexity over dozen of modules rather than to think that the onboarding process would be simple, because it’s just a standard Vue application, and that there is nothing tricky about it.
Outcomes
Our goals were to increase maintainability and quality. Did we hit those?
- The isolated nature of modules and explicit dependencies between them cause the changes to affect a lesser scope
- The new architecture allows for easier testing so more bugs are found during development
The work is not done yet, every changeset raises new questions and uncovers topics we need to address. But so far, we are very satisfied with our results.