maciej łebkowski
Maciej Łebkowski

The path to Docplaner Phone’s frontend app architecture

in Professional

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 henry 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.

Was this interesting?

About the author

My name is Maciej Łebkowski. I’m a full stack software engineer, a writer, a leader, and I play board games in my spare time. I’m currently in charge of the technical side of a new project called Docplanner Phone.

Creative Commons License
This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License. This means that you may use it for commercial purposes, adapt upon it, but you need to release it under the same license. In any case you must give credit to the original author of the work (Maciej Łebkowski), including a URI to the work.