Skip to content

Services

Walter Kaunda edited this page Oct 21, 2021 · 2 revisions

Services

This section continues from where the Developer Guide left on services. As introduced in that document, services make up the business service layer of the application. A request comes in, a controller parses and validates the request then hands over control to a service that does further processing. The service layer processes the operation requested by the controller and returns either an object as the output or raises an exception in case of an error. NOTE: Simple operations like "give me all of this entity" can be handled at the controller level (it's just Model.all after all).

There are mainly two kinds of services you will run into within this application. The first kind of service just provides methods for operations within a single sub-domain. We will call these Basic services. These services are mostly wrappers for direct CRUD operations on the data layer. The other kind of service is one that acts as an Abstract Factory for various program specific subservices. Normally, the basic service layer should be enough to meet the majority of the needs of most EMRs. However, the EMRs at some point start to differ in their needs. For example, most EMRs have a visit summary at the end of every visit, however the contents of these summaries differ. ART needs the latest viral load information on the visit summary and on the other hand might need some information to do with the current state of a pregnancy. The idea behind these services comes in to address these differences. We have the same concept in different EMRs but that concept is expressed differently within each EMR. So we end up having one endpoint for performing some operation that varies in implementation for different programs. The variation is enabled having a factory provide the correct implementation for a given program (loosely corresponds to an EMR). For example, we have a single endpoint for sourcing reports at GET programs/{program_id}/reports/report_name. The controller gets this request, then instantiates the ReportService with the program_id and calls the #generate_report method on it. The ReportService internally uses a Factory to find the correct implementation to call. For lack of a better name, we will call these Program Engine Loader Services. The following sections provide details on how to go about implementing services.

Basic Services

These are quite easy to implement and follow. They can be implemented as a class or a module. A class is preferred where the service is holding some state for example a session date (you don't have to manually pass the date to every method you call in that service). In controllers, we usually add a wrapper method to abstract away creation of loading of these services. Controllers shouldn't really care whether the service is a module or a class and if you decide to change the service's implementation, you only have one place to change.

# /app/controllers/api/v1/foobar_controller.rb
class Api::V1::FoobarController < ApplicationController
  def index
    filters = params.permit(%i[fooname other_param])

    render json: foobar_service.search(filters)
  end
  
  private
  
  def foobar_service
    FoobarService.new(session_date)
  end
  
  def session_date
    params[:date]&.to_date || Date.today
  rescue Date::Error => e
    logger.error("Could not parse date `#{params[:date]}`: #{e}")
    raise InvalidParameterError, "Invalid date: #{params[:error]}"
  end
end

# /app/services/foobar_service.rb
class FoobarService
  def initialize(date = nil)
    @date ||= date
  end
  
  def search(params)
    # Do some crazy search operation
  end
end

Program Engine Loader Services

As pointed out elsewhere, these services are just glorified Abstract Factories. Initially, they were implemented for each feature, for example if you need a generic way of handling reports among various programs then you would create a ProgramReportsService. Then for each program, a handler (called an engine in this application) would be registered. The engine loader thus would instantiate the correct engine when given a program. This approach was preferred as the prevailing thought at the time was that it would make it easier for anyone reading the code to follow what is going on. Having the loaders guess the correct engine to instantiate isn't difficult but it was feared that this may be too magical for most people to follow. However the approach chosen led to a lot of code duplication among the loaders. To simplify this process the ProgramEngineLoader was implemented, what this simply does is: given a program and an engine name, it finds the correct engine in any program. For example, if a controller is interested in ART's PatientEngine then it simply calls the ProgramEngineLoader and specifies the ART Program and the name 'PatientEngine'.

# Example: Fetch an ART patient visit summary using the loader
ProgramEngineLoader.load(Program.find_by_name('ART Program'), 'PatientsEngine')
                   .new
                   .patient_visit_summary(patient_id, Date.today)

What's an Engine?

An engine is just a module or class that implements an interface for a certain operation in the context of a given program. To put it in a more practical sense, say you an operation that is required for a given program. An example could be a patient's medical history. Now, to implement this feature you will come up with interface for the medical history. You will have to define what operations you expect to perform on the medical history, in our case we only have one operation that is retrieving the medical history. As such you will end up with something that looks like:

# NOTE: This is just for illustration purposes, we don't explicitly
# define our interfaces as is the case with most dynamically typed
# languages.
module PatientMedicalHistory
  def new(patient); end

  def from(start_date); end
end

The next step is to come up with a class that implements this interface for a particular program. This implementation is what we are referring to as the engine here.

Unfortunately, there are no generally accepted standards for documenting interfaces in Ruby. In this application, the only documentation available for an interface is the initial implementation of the interface. The public API is what you must take as the interface.

Problems and possible way forward

In as much as the engine pattern described above, allowed us to customise various operations for each program, there are still some limitations. Different programs have different needs and sometimes a need comes along that is super specific to a single program. It is possible to create an engine for this need and expose it at some program endpoint, however this operation will not make sense in the context of the other programs. Thus you may end up with lots of endpoints that only cater to one specific program. And in some cases, the operation might make sense but requires wildly different parameters. Coming up with a general endpoint to handle all these parameters and possibly have some validation for these becomes problematic. So, if you go into config/routes you will notice that there are a lot of dangling endpoints that seem to cater for single programs and have no use elsewhere. For someone new to the project, some of the endpoints might simply be confusing as hell (what does GET /regimen_starter_packs or GET archiving_candidates, what the hell is that?).

Another problem that was encountered during development of this application is coordination among development teams. The core application has a dedicated development team however from time to time other development teams come in to extend the application to work with their programs. For example, the ANC development might come in to extend the application by maybe adding various engines for the ANC program, similarly the TB and OPD teams might come in to do the same for their programs. Inevitably, in this sort of setup things become difficult to manage. Compound this with the lack of documentation, the application becomes a big ball of mud that's hard to follow. Even reviewing the changes made by the other teams becomes too much of a challenge for the core development team.

After looking into a number of possible solutions (which include having proper documentation), we arrived on the idea of adopting a model similar to that of Django's applications. A Django project comprises of one or more applications. You have this host umbrella project to which various related applications are plugged into. The applications have direct access to each other's public APIs if need be. This approach could solve most of our problems as we could have this single primary app that hosts the core data access operations on the OpenMRS-like model. We looked into how a similar approach can be followed in Rails and we landed on Rails engines. A Rails engine is a miniture application that extends the capabilities of a Rails application. Examples of popular Rails engines include Devise and ActiveAdmin. As a proof of concept we built the Lab extension that's hosted here. This extension replaces the opaque lab functionality that was previously embedded in this application (endpoints at /programs/{program_id}/lab/*). This separation of the lab functionality from the main allowed us to develop a much richer app without cluttering the main application. The plan for the future is that all the custom program functionality be moved to their own Rails engines eventually. This implies the death of the program engines.

A tour of some of the available services

  • ARTService: This isn't a service per say but rather a package of various engines. It can be used as the de facto source of most engine's interfaces. It was the first program to be implemented and is the originator of the entire engine pattern in this application. This also hosts a number of reports that are accessed from the #ReportsEngine.
  • Other program packages: They all serve a similar role to the ARTService above. They are hosts of various engines for the program.
  • AppointmentService: An entrypoint for various appointment centric operations. It utilises the appointment engine interface.
  • AuthenticationService: Encapsulates all the authentication protocols (please add JWT)
  • DDEService: Holds all the business logic for interacting with DDE-like external services.
  • DDEMergingService: Provides local and DDE patient merging services. Even in the absence of a connection to DDE this can be used local patients (and infact it's used for that purpose)