-
Notifications
You must be signed in to change notification settings - Fork 10
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.
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
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)
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.
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.
- 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)