Skip to content

SOLID Principles

Kyle MacMillan edited this page Nov 25, 2024 · 1 revision

This document will serve to be a guideline for design patterns within va-enp-api.

SOLID

Code should follow the SOLID principles:

  • Single Responsibility
    • Functions should exist for a single purpose and as soon as they start to do more than one thing they are broken apart.
  • Open-closed
    • Class functions are a great example. Uses the class member or instance variables
  • Liskov Substitution
    • OOP principle, intended to allow things inheritance to work effectively with interfaces ("contracts").
  • Interface Segregation
    • A good example of this is how we have a PushModel and intend to have SmsModel and EmailModel. The interface should not be overloaded to make sms requests require a "subject" field, which is required by email.
  • Dependency Inversion
    • High level functionality should not depend on low level functionality.
    • A route needs to add or collect data from the database and could do so directly, but that is tightly coupled. DI says that the route should interact with an interface to collect the data it needs.

Single Responsibility

Non-single responsibility Example:

def process_the_thing(data):
    try:
        value_one = data['value_one']
        value_two = data['value_two']
    except: KeyError
	    logger.exception('Unable to proccess the thing')
	    raise NonRetryable

	thing_one.update(value_one)
	thing_two.v2 = value_two

This trivial example has a parsing and update section. These are two separate things necessary to end up at the same result: a processed thing.

These should always be broken apart.

def parse_the_thing(data):
    try:
        value_one = data['value_one']
        value_two = data['value_two']
    except: KeyError
	    logger.exception('Unable to proccess the thing')
	    raise NonRetryable
	return Thing(value_one, value_two)

def update_the_thing(thing):
	thing_one.update(value_one)
	thing_two.v2 = value_two

def process_the_thing(data):
	thing = parse_the_thing(data)
	logger.debug('the thing processed is: {}', thing)
	update_the_thing(thing)

Easier to maintain, extend, and test! Simpler to piece together what the function process_the_thing is accomplishing. Then if you need to understand the parsing, you can drill into that.

A real example of this principle being violated is in the notification-api repo, the process_pinpoint_results method does all of the parsing in-method, that is a good example of something that could/should be broken apart.

Open-closed

The ability to extend code with no modification to existing code.

One aspect of open/closed is inheritance. BaseDao below has the serialize functionality.

def BaseDao(Base):
	def serialize():
		...

Let's extend it to allow to represent a templates object and allow it to add template rows.

def BaseDao(Base):
	def serialize():
		...

def TemplateDao(BaseDao):
    async def add(data):
        ...

We can extend this class by adding a get method, which has no bearing on BaseDao nor add:

def TemplateDao(BaseDao):
    async def add(data):
        ...

	async def get(id):
		...

Liskov Substitution

BaseDao contained a way to serialize the data of that object.

def BaseDao(Base):
	def serialize():
		...

So following Liskov Substitution, we should be able to do the following:

template = TemplateDao.add(template_data)
print(template.serialize())

Every derived class should be capable of using the serialize method.

Interface Segregation

This principle involves ensuring clients do not have to implement downstream methods or deal with downstream parameters.

The notification-api has a good example of Interface Segregation violation in the send_sms methods, where they require parameters be passed that are never used.

An example of how to avoid stepping over the line is the va-enp-api send_notification method. We want to ensure all notifications are handled in the same manner, so regardless of the intent to send an sms, we want to ensure we call send_notification which then calls send_sms. This does not violate Interface Segregation because the sms request does not have to implement anything related to send email or push notifications. It also does not need to include email or push parameters, nor additional sms parameters for various providers.

Dependency Inversion

This principle covers how different modules/classes within the application interact. Database is probably the most common example of this. In some APIs you may directly interface with the database inside the route which is putting all mechanics and utilities in the policy layer of an application.

We want to separate out the different layers (conceptually). Routes for example handle the following layers:

  • Authentication
  • Validation (2-step process)
  • Parsing
  • Doing the thing (composed of more layers)
  • Returning a response
  • Any background tasks

Authentication, Validation, and Doing the thing all reach out to the database. We want all database calls handled in the same way (success or failure), so it makes sense to create a database utility layer to facilitate the other layers.

Wikipedia states it well:

By dictating that both high-level and low-level objects must depend on the same abstraction, this design principle inverts the way some people may think about object-oriented programming.

This can be difficult for developers, so please reach out!

Conclusion

By properly using polymorphism and dataclasses/models we are able to avoid many of the pitfalls seen with our current notification-api.