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
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:
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
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:
-
- Difficulty in adding new vehicle types to the fleet.
-
- Managing Variations in Vehicle Features will be Cumbersome.
-
- 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:
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
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
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
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.
- A
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 @patch
decorator 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
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
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
-
Update the
test_car.py
module to include a new test case for the new tire servicing criteria. - Add new tests for tire objects and tire servicing criteria.
- Run the tests to ensure that the new tests fail.
-
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 thetire
directory. - Run the tests to ensure that the new tests pass.
Results
After completing the tasks, I achieved the following 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:
-
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.
-
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.
-
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 theunittest
module to mock the creation of objects. I also discovered a neat way to measure the coverage of tests inside the codebase using thecoverage
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.