What I learned as a Junior Backend Software Engineer at Lyft - A Forage Job Simulation Experience

Tags: Design Patterns Python Programming Test Driven Development Unittesting

Lyft Image

Author: Simon Tagbor πŸ“… 10th March, 2024

Introduction

I did a job simulation as a junior backend software engineer at Lyft on Forage.

The goal was straight forward:

Implement a Re-design of an Existing Codebase for Lyft’s Rental Fleet Logistics System.

The New design is expected to make the Codebase more modular and scalable.

The implementation of the new design is also expected to have extensive test coverage

If you’re curious about trying this job simulation feel free to check out the full experience on Forage

I Thoroughly enjoyed the experience and in this blog post, I will share the main highlights of my process.

Feel free to reach out to me on X @clawd_johan if you have any questions or feedback.

Let’s begin by looking at the context of the problem and the existing codebase.

Situation

I inherited a codebase from a previous engineer who started working on a new system for managing the maintenance of the types of vehicles in LYFT’s rental fleet. The previous engineer had to be assigned to another project. As a junior backend engineer, I was tasked to build on his existing work

From an Object Oriented Program design perspective, the codebase left behind by the engineer is not easily extensible and maintainable. The design relies almost entirely on inheritance to model vehicles in LYFT’s rental fleet, complete with their servicing criteria.

Consequently, the codebase has been riddled with components that have deep inheritance hierarchies. Servicing criteria may change frequently as LYFT’s rental fleet evolves. Introducing new updates in the current codebase will require making changes to many parts of the codebase, thereby increasing the chances of introducing several time-consuming bugs while also running the risk of duplicating code

Even more so, the codebase lacks unit tests with extensive coverage. LYFT fleet management system would be better off with a new and extensively tested design of the backend business logic

Tasks

For this project, I completed the following tasks:

  • Task 1: Re-designed backend Business Logic for Fleet Management System. βœ…
  • Task 2: Refactored and Optimized Codebase βœ…
  • Task 3: Wrote Extensive Unittests βœ…
  • Task 4: Implement a New Tire Servicing Criteria. βœ…

Actions

1. Backend Business Logic Re-Design

Design Challenges With The Existing Codebase

Current Design - LYFT Rental Fleet logistics System Component drawio (1)

As can be seen from the above UML diagram, The Previous Engineer used a convoluted inheritance hierarchy to handle the different types of vehicles in the fleet.

The Engineer created different car models in LYFT’s fleet by subclassing three unique engine types which are also by themselves, an extension of Car and ABC parent classes.

The 3 Key Pain Points Identified in the Existing Codebase:

    1. Difficulty in adding new vehicle types to the fleet.

      1. Managing Variations in Vehicle Features will be Cumbersome.

        1. Convoluted Inheritance Hierarchy makes the codebase tightly coupled

Finding Solutions

After gathering enough details about the goals of the fleet management system and familiarising myself with the codebase, I looked into design patterns that could help address the key design challenges.

I used a combination of design patterns and object-oriented program design principles to sanitize the codebase. The thought process behind the design choices is further discussed below:

Strategy Design Pattern

This design pattern choice felt more natural for instances where we will be handling different vehicles with unique algorithms for determining servicing needs. A typical strategy design pattern approach would involve creating a Serviceable interface - an abstract class of anything that can be serviced with a needs_service abstract method. Different Car subclasses of the interface would then encapsulate their own needs_service algorithms for determining when its instances should be serviced.

The strategy design pattern makes it more straightforward to add new vehicles and it also ensures that each new Car variant has its own strategy for determining servicing needs implemented. For instance, LYFT may decide in the future to add a Motorbike and Segway to its fleet. The Serviceable interface makes it easier to extend the codebase to meet the new requirements with fewer breaking changes. You can read more about about The strategy design pattern Here

Factory Design Pattern

Considering that our fleet management system will be instantiating several vehicle types across the codebase, it will be helpful to use a creational design parttern to organize the logic of how vehicles in the fleet get created in one place. Similar to other creational design patterns, the factory design pattern attempts to encapsulate the logic for creating variants of Car instances in one well-defined CarFactory class interface.

This approach abstracts how a Car instance gets created, making it possible to isolate the client from having to know how the instances get β€œassembled”. For example, if the specification requires that a Calliope car object should have a particular Engine, Battery and Tire components, the CarFactory interface will provide a .create_calliope_car(…) public method that puts the right components together and returns a calliope car object to the client.

The factory design pattern allows for a much cleaner codebase and any problem with our object creation logic can be easily isolated during debugging because now the creation of objects is independent of the client that uses them. you can learn more about the Factory Design Pattern here

Composition Over Inheritance

I used the design principle of β€œcomposition over inheritance” to address the pain point of complex and deep inheritance hierarchy in the codebase. In the current design, components are too tightly coupled making the codebase less modular. favoring composition - where we create a complete unit of components(also known as composites) that are independent of other components and assign them as attributes of a class that depends on them.

For example cars in LYFT’s fleet will need to have Engine and Battery components to determine servicing needs. The inheritance approach would be to let the Car class subclass Engine and Battery classes. This way, the car instances would have all the attributes they need to determine servicing needs. This approach is not only error-prone, it also results in duplicated code.

With composition, we simply treat Engine and Battery as independent classes that can be used by other classes as shown by the Car class below:

import Engine
import Battery

class Car(Serviceable):
    ....
    def __init__(self, engine: Engine, battery: Battery):
        self.engine = engine 
        self.battery = battery

For our use case, not only does choosing to favor composition over inheritance make the codebase more modular, but it also makes the design more intuitive as it models the real-world relationships between a car and its components. It feels more natural to say a Car HAS A Battery and an Engine than it is to say a Car IS A Battery and an Engine combined. You can learn more about the principle here: Inheritance and Composition

After these considerations I used the UML diagram to propose the new design changes. The final design was submitted for review and approval. The UML diagram below shows the proposed changes to the codebase.

After Backend Logic Redesign

Updated Design - LYFT Rental Fleet logistics System Component (1)

2. Codebase Refactoring

After sharing the new design and getting the green light to implement it, I refactored the codebase based on the new design.

The new codebase has the following updates:

New Directory Structure After Refactoring

I restructured the codebase to make it more organized and maintainable. The new directory structure is as follows:

β”œβ”€β”€ .
β”‚   β”œβ”€β”€ serviceable.py
β”‚   β”œβ”€β”€ car.py
β”‚   β”œβ”€β”€ car_factory.py
β”‚   β”œβ”€β”€ .gitingore
β”‚   β”œβ”€β”€ README.md
|   β”œβ”€β”€ battery
|   |   |── __init__.py
|   |   β”œβ”€β”€ base_battery.py
|   |   β”œβ”€β”€ spindler_battery.py
|   |   β”œβ”€β”€ nubbin_battery.py
|   β”œβ”€β”€ engine
|   |   |── __init__.py
|   |   β”œβ”€β”€ base_engine.py
|   |   β”œβ”€β”€ capulet_engine.py
|   |   β”œβ”€β”€ wiloughby_engine.py
|   |   |── sternman_engine.py
|   β”œβ”€β”€ test
|   |   β”œβ”€β”€ __init__.py
|   |   β”œβ”€β”€ test_car.py
|   β”œβ”€β”€ utils
|   |   |── __init__.py
|   |   β”œβ”€β”€ input_validator.py

The new structure is now more organized and maintainable.

Based on the Factory Method Pattern, We will not need to create new models to represent new fleets of cars. We can simply create a new class that inherits from the base class and implement the methods that are specific to the new fleet of cars. we will no longer need the /engine/models/ directory where these specialized car models were defined.

The new structure also includes:

    • A battery directory to define battery components for car objects.
    • A utils directory to define input validation and other helper functions for creating components and cars.
    • Removal of the engine/model directory.
    • Updated engine directory files to reflect the new design.
    • A car.py module to reflect the new design.
    • A car_factory.py module to leverage the factory method and strategy design patterns.
    • A serviceable.py module to define an interface for different types of vehicles.

3. Extensive Unit testing of the Codebase

I wrote unit tests to test the new codebase. The unit tests are located in the test directory. The unittests cover the following:

  • Test the creation of different types of vehicles. βœ…
  • Test the creation of different types of engines. βœ…
  • Test the creation of different types of batteries. βœ…
  • Test that the Car class is correctly implemented. βœ…
  • Test that the Base Engine and Base Battery classes are correctly implemented. βœ…
  • Test that the derived classes from the Base Engine and Base Battery classes are correctly implemented. βœ…
  • Test the input validation for creating components and cars. βœ…

Testing Approach

I mostly wrote standard unit tests, however, in some cases I used @patchdecorator to mock the creation of different types of vehicles, engines, and batteries using the Factory Method Pattern- Implemented Via the car_factory.py module. I did that to ensure that the tests are independent of the client that uses them.

Mocking the creation of objects allowed me to focus on testing the functionality of the code in isolation, without relying on the specific details of the instantiated objects.

Test Coverage

Test Coverage Report by coverage Python package

see the full test coverage report here

In total, the code coverage is about 99% and all tests 71 tests passed. The only module not tested is the serviceable.py which is an interface for serviceable objects like cars. I can safely assume that the interface is correctly implemented since all the derived classes from the interface are acting as expected.

python3 -m unittest -v

Results from Running Uittest in Verbose mode

Task 4 - Implement New Tire Servicing Criteria

Now that the codebase is more maintainable and scalable, I implemented new tire servicing criteria. However, I used the Test-Driven Development (TDD) approach to implement the new tire servicing criteria.

Test-Driven Development (TDD) Approach

Test-driven development (TDD) is a software development process that relies on the repetition of a very short development cycle: first, the developer writes an (initially failing) automated test case that defines a desired improvement or new function, then produces the minimum amount of code to pass that test, and finally refactors the new code to acceptable standards.

My Steps
  1. Update the test_car.py module to include a new test case for the new tire servicing criteria.
  2. Add new tests for tire objects and tire servicing criteria.
  3. Run the tests to ensure that the new tests fail.
  4. Implement the new tire servicing criteria in the car.py module by ensuring that the car class has tire attributes. Implement tire-related classes and tire servicing criteria in the tire directory.
  5. Run the tests to ensure that the new tests pass.

Results

After completing the tasks, I achieved the following results:

Results
  • The backend Business Logic for the Fleet Management System is now Modular and Scalable. ⭐
  • The codebase now has a test coverage of 99%. ⭐
  • The new tire servicing criteria were implemented and tested to spec. ⭐
  • The codebase is now well documented to improve collaboration with other developers. ⭐

Sanitized Codebase at a Glance

β”œβ”€β”€ .
β”‚   β”œβ”€β”€ serviceable.py
β”‚   β”œβ”€β”€ car.py
β”‚   β”œβ”€β”€ car_factory.py
β”‚   β”œβ”€β”€ .gitingore
β”‚   β”œβ”€β”€ README.md
|   β”œβ”€β”€ battery
|   |   |── __init__.py
|   |   β”œβ”€β”€ base_battery.py
|   |   β”œβ”€β”€ spindler_battery.py
|   |   β”œβ”€β”€ nubbin_battery.py
|   β”œβ”€β”€ engine
|   |   |── __init__.py
|   |   β”œβ”€β”€ base_engine.py
|   |   β”œβ”€β”€ capulet_engine.py
|   |   β”œβ”€β”€ wiloughby_engine.py
|   |   |── sternman_engine.py
|   β”œβ”€β”€ tire
|   |   |── __init__.py
|   |   β”œβ”€β”€ base_tire.py
|   |   β”œβ”€β”€ carrigan_tire.py
|   |   β”œβ”€β”€ octo_prime_tire.py
|   β”œβ”€β”€ test
|   |   β”œβ”€β”€ __init__.py
|   |   β”œβ”€β”€ test_battery
|   |   |   β”œβ”€β”€ test_battery.py
|   |   β”œβ”€β”€ test_car
|   |   |   β”œβ”€β”€ __init__.py
|   |   |   β”œβ”€β”€ test_car.py
|   |   |   β”œβ”€β”€ test_car_factory.py
|   |   β”œβ”€β”€ test_engine
|   |   |   β”œβ”€β”€ __init__.py
|   |   |   β”œβ”€β”€ test_engine.py
|   |   β”œβ”€β”€ test_tire
|   |   |   β”œβ”€β”€ __init__.py
|   |   |   β”œβ”€β”€ test_tire.py
|   |   β”œβ”€β”€ test_utils
|   |   |   β”œβ”€β”€ __init__.py
|   |   |   β”œβ”€β”€ test_input_validator.py
|   β”œβ”€β”€ utils
|   |   |── __init__.py
|   |   β”œβ”€β”€ input_validator.py

Key Learning Outcomes

I enjoyed working on this project and learned a lot about the following:

  1. Design Patterns: I learned about different design patterns such as the Factory Method Pattern and Strategy Pattern. I learned how to use these patterns to create a more scalable and maintainable codebase. I will always have it in mind that different design patterns can be used to solve different problems and understanding the problem domain is key to choosing the right design pattern.

  2. Test-Driven Development (TDD): having known about the concept for a while, I finally got to use it in my workflow as a developer. I saw firsthand that TDD is a great way to ensure that the code is working as expected and to catch bugs early in the development process.

  3. Unit Testing: I reinforced my understanding of unit testing techniques and I am now committed to writing extensive unit tests in future projects. I also learned how to use the @patch decorator from the unittest module to mock the creation of objects. I also discovered a neat way to measure the coverage of tests inside the codebase using the coverage package.

Feel free to reach out to me on X or LinkedIn if you have any questions or feedback about this project. I would love to hear from you.