Skip to content

An example approach for implementing a Clean/Hexagonal Architecture In typescript

Notifications You must be signed in to change notification settings

iifawzi/parcels-delivery-service

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Parcels Management System

Screen Shot 2022-12-23 at 6 20 37 PM

Introduction 📺

Parcels Management Service consists of the backend and the dashboard that will allow the customers to create shipments and bikers to pick and deliver them.

In this document I will give you an overview about the project, grab a cup of coffee ☕, there're a lot of technicalities and fun.

Requirements

• A sender should be able to create a parcel to be delivered by specifying pick-up and drop-off address (should be just a text field, no need for address validation)

• A sender should be able to see the status of his parcels.

• A biker should be able to see a list of the parcels.

• A biker should be able to pick up a parcel.

• Once a parcel is picked up by a biker, it cannot be picked up by other bikers.

• A biker should be able to input the timestamp of the pickup and the delivery for each order.

• The status of the order should be updated for the sender.


In the rest of the document, I will be discussing how I tried to achieve the requirements and the technical decisions around it.

tl;dr - Installation

All of the system components is configured in the docker-compose.yaml file, that you don't need anything other than

  • Clone the project and cd into it
  • Docker compose up
  • Enjoy!

The backend will be running at http://localhost:4040/api/
The frontend will be running at http://localhost:3005/auth/

For the customers login info:

user: customer1, customer2, customer3, customer4 or customer5
pass: password

For the bikers login info:

user: biker1, biker2, biker3, biker4, biker5, biker6, biker7, biker8, biker9 or biker10

pass: password

Note: if you're willing to run the backend locally, without docker, don't forget to change the module alias configuration in package.json to:

  "_moduleAliases": {
    "@": "."
  },

Principles and goals


Since day 1, principles and goals were set to deliver a very high quality and fulfill the requirements in an elegant, and modern way with giving the testability and modularity the highest priority.

Architecture - Backend

For the backend, I've used typescript with express, and tried as much as possible to follow the SOLID Principles.

Files Structure

There're multiple folders and files created to preserve the modularity and for making classes and functions more focused adhering to the single responsibility below is the file structure of the services, where each service have its tests and repositories implementations along with the other REST needed files ( router, controller, validation )

Screen Shot 2022-12-23 at 5 04 33 PM

More technical details and information are explained below.

Deep Dive

Firstly, for the server instantiation, and the database connection creation, I've used the Singleton pattern to have a single instance of each, at any time

export default class Server {
private static serverInstance: http.Server;
private constructor(private app: BaseApp) {
this.app = app;
const port = process.env.PORT || 4040;
Server.serverInstance = http.createServer(this.app.getInstance);
Server.serverInstance.listen(port, () => {
if (process.env.NODE_ENV != 'test') {
console.log(`Server is listening on Port ${port}`);
}
});
};
public static getServer(app: BaseApp): http.Server {
if (!Server.serverInstance) {
new Server(app);
}
return Server.serverInstance;
}
}

export class Database implements BaseDatabase {
private instanceCreated = false;
constructor(@inject(TOKENS.logger) private logger: BaseLogger) {
this.logger = logger;
}
private createConnection(): any {
const connectURI = Locals.config().MONGO_URL;
mongoose.connect(connectURI, (error: mongoose.CallbackError) => {
if (error) {
this.logger.info('Failed to connect to the Mongo server!!');
throw error;
} else {
this.logger.info('connected to mongo server at: ' + connectURI);
}
});
};
public getConnection() {
if (!this.instanceCreated) {
this.createConnection();
this.instanceCreated = true;
}
return mongoose;
}
}
export default mongoose;

Dependency Inversion

The D in SOLID was one of the most principles that I was focusing on not to break, I've really experienced before, the mess that occurs when we decide after months of work, that we need to change the database for example! what a mess that would be. I've fallen into it one day. thus for critical service I've been always keeping the Program to an interface not implementation rule, in mind.

I've used https://github.com/microsoft/tsyringe from microsoft as a lightweight dependency injection container, which made me able to inject the necessary services or modules in an easier, and controllable way.

export default function regesterDependencies() {
// Common Dependencies
container.register<BaseLogger>(TOKENS.logger, { useClass: ConsoleLogger }, { lifecycle: Lifecycle.Singleton });
container.register<BaseDatabase>(TOKENS.database, { useClass: Database }, { lifecycle: Lifecycle.Singleton });
// Biker dependencies
container.register<BikerService>(TOKENS.bikerService, { useClass: BikerService }, { lifecycle: Lifecycle.Singleton });
container.register<BikerRepositoryI>(TOKENS.bikerRepository, { useClass: BikerRepositoryMongoDB }, { lifecycle: Lifecycle.Singleton });
// Customer dependencies
container.register<CustomerService>(TOKENS.customerService, { useClass: CustomerService }, { lifecycle: Lifecycle.Singleton });
container.register<CustomerRepositoryI>(TOKENS.customerRepository, { useClass: CustomerRepositoryMongoDB }, { lifecycle: Lifecycle.Singleton });
// Shipment dependencies
container.register<ShipmentService>(TOKENS.shipmentService, { useClass: ShipmentService }, { lifecycle: Lifecycle.Singleton });
container.register<ShipmentRepositoryMongoDB>(TOKENS.shipmentRepository, { useClass: ShipmentRepositoryMongoDB }, { lifecycle: Lifecycle.Singleton });
}

Thanks to the decorators, and our base abstractions we can simply program to interfaces and make tsyringe handles the rest.

The diagram below explains how these layers are communicating with each other, with the help of our container:

parcels-diagram excalidraw

By adhering to this architecture, the Business logic is fully isolated, and can be developed and tested independently, this can be seen in the Biker.service, Customer.service or Shipment.service, at which you will find that they're only applying business logic, and just communicating with the data layer interface ( not an implementation )

for example, the customer service

@injectable()
export default class CustomerService {
constructor(@inject(TOKENS.customerRepository) private customerRepository: CustomerRepositoryI, @inject(TOKENS.logger) private logger: BaseLogger) {
this.customerRepository = customerRepository;
this.logger = logger;
}
public async login(username: string, password: string): Promise<any> {
this.logger.info(`CustomerService :: login :: ${username}`);
const customer = await this.customerRepository.findCustomer(username);
if (!customer) {
this.logger.error(`CustomerService :: login :: 401 :: ${username}`);
return [false, null];
}
const passwordIsSame = await comparePassword(password, customer.password);
if (!passwordIsSame) {
this.logger.error(`CustomerService :: login :: invalid password ::`);
return [false, null];
}
delete customer.password;
const token = createToken({ ...customer, role: 'customer' });
return [true, { ...customer, token }];
}
}

Such implementation of the services with the Repository pattern

export default class BikerRepositoryMongoDB implements BikerRepositoryI {
private bikerModel = BikerModel;
public async findBiker(username: string): Promise<any> {
const biker = await this.bikerModel.findOne({ username }).lean();
return biker;
}
}

is making the domain use-cases totally independent from the infrastructure decisions, Because from our services point of view, it does not matter if our data is being stored in PostgreSQL, MongoDB or even locally, as long as we have a class that implements the interface and provides the methods we need everything is supposed to work.

And this's actually what I've made to Mock the database in the e2e tests.

export default class CustomerRepositoryMock implements CustomerRepositoryI {
private customers: Record<string, Record<string, any>> = {
customer1: {
_id: "63a22b00a704bee4b0254f56",
username: "customer1",
password: "$2b$05$ADWgYlrf0.Szt/tJRZ4vt.Na/EIBIlpGYmM5y26GIAqErUuzZkfDi",
fullName: "Customer Number 1"
},
customer2: {
_id: "63a22b00a704bee4b0254f4d",
username: "customer2",
password: "$2b$05$ADWgYlrf0.Szt/tJRZ4vt.Na/EIBIlpGYmM5y26GIAqErUuzZkfDi",
fullName: "Customer Number 2"
}
};
async findCustomer(username: string): Promise<any> {
if (this.customers[username]) {
return this.customers[username];
}
return null;
}
}

Controllers and Infrastructure

Preserving the decoupling between the components, puts some challenges, as our services layer, shouldn't know anything about the infrastructure layer it shouldn't care or be impacted, if we used Express.js or Nest.js, a REST API, or maybe just a plain socket layer as main communication channel!

at our case, i'm using REST APIs, with express.js, thus the controllers are looking as following, for example the shipment controller:

@injectable()
export default class ShipmentController {
constructor(@inject(TOKENS.logger) private logger: BaseLogger, @inject(TOKENS.shipmentService) private shipmentService: ShipmentService) {
this.logger = logger;
this.shipmentService = shipmentService;
}

The service is injected, and is managed by the controller, this also have put some challenges into the services implementation, as I needed to avoid throwing any HTTP Exceptions from them, because if we later decided not to use REST APIs, those errors from the services will break the decoupling, and we will then need to change the logic in the services, which's not really preferred IMO. We want to make the services fully isolated and independent.

Here's how I managed to avoid throwing errors in the services:

public async pickupShipment(shipmentInfo: PickupShipmentInfo): Promise<any> {
this.logger.info(`ShipmentService :: pickupShipment :: ${JSON.stringify(shipmentInfo)}`);
const shipment = await this.shipmentRepository.findShipmentByIdAndBiker(shipmentInfo.shipmentId, shipmentInfo.biker);
if (!shipment) {
return [false, 'notfound']
}
if (shipment.shipmentStatus !== ShipmentStatus.MATCHED) {
return [false, 'notmatched']
}
const updatedInfo = { ...shipmentInfo } as Record<string, string>;
delete updatedInfo.shipmentId;
const updatedShipment = await this.shipmentRepository.updateShipment(shipmentInfo.shipmentId, updatedInfo);
return [true, updatedShipment];
}

This's not the best way though, if I had more time, I'd rather prefer to have domain exceptions, that can be thrown from the services, and then with a simple switch case for example, errors can be checked in the controllers:

const [status, error] = await this.shipmentService.matchShipment(shipmentInfo);
if (!status) {
    switch (error) {
        case instanceof NotFoundShipment:
            throw new BaseError(409, 'Shipment is not found');
            case instanceof NotAllowedForPickingShipment:
            throw new BaseError(609, 'Shipment can\'t be picked');
    }
}

Where NotFoundShipment and NotAllowedForPickingShipment are domain exceptions, this way, the service is fully independent and don't need to know anything about the higher modules and layers, and in fact also if I've had more time I'd prefer to implement a more well-typed errors and success responses to returned from the services. Thanks to khalil stemmler, he have explained it very well here https://khalilstemmler.com/articles/enterprise-typescript-nodejs/functional-error-handling/.

Testing

I've put a huge efforts into testing to make the code fully covered, and that's what i've actually learned from contributing to open source, tests is one of the most important aspect, tests make us able to fix, debug, and add features more faster and easier, with being sure we didn't break anything.

I've written almost 42 test case, and made it possible to run them against a real database or independently without the need of database connection ( thanks to DIP ).

Screen Shot 2022-12-23 at 5 13 38 PM

You can run the following commands for testing:

"test:e2e": "NODE_ENV=test jest --runInBand",
"test:integration": "NODE_ENV=integration jest --runInBand --detectOpenHandles",
"test:e2e-cov": "NODE_ENV=test jest --coverageDirectory='e2e-coverage' --coverage --runInBand",
"test:integration-cov": "NODE_ENV=integration jest --coverageDirectory='integration-coverage' --coverage --runInBand",
"test:merged-cov": "npx istanbul-merge --out ./coverage.json ./integration-coverage/coverage-final.json ./e2e-coverage/coverage-final.json && npx istanbul report --include coverage.json --dir full-coverage html",
"test:clean-files": "rm -r ./e2e-coverage && rm -r ./integration-coverage && rm ./coverage.json",
"test:cov": "npm run test:integration-cov && npm run test:e2e-cov && npm run test:merged-cov && npm run test:clean-files",

npm run test:e2e // for testing without database
npm run test:integration // communicating with a real database
npm run test:cov // will run both tests and gather the coverage reports. 

Note: before running the commands on your local machine, you need to change the module alias configuration in the package.json to:

  "_moduleAliases": {
    "@": "."
  },

Architecture - Frontend

For the frontend side, I've used REACT, SCSS and Typescript, responsitivity and modularity were kept in mind too while implementing the frontend dashboard, I've tried as much as possible to divide THE files and the components in a well-structured manner, for better reusability of the shared components and for making it easier to develop and maintain the code.

I've also used some components from Material UI

Below is the file structure of the application:

Screen Shot 2022-12-23 at 5 43 07 PM

Architecture - Frontend

State Management

I've used the useReducer hook along with the Context for the state management across the application, and the simple useState hook, for in-component state. The contexts are defined in the contexts folder, and the providers with the reducers are defined in the providers folder

export default function AuthContextProvider({ children }: ProviderProps) {
const [state, dispatch] = React.useReducer(authReducer, InitialAuthState)
return (
<>
<AuthContext.Provider value={{ state, dispatch }} >
{children}
</AuthContext.Provider>
</>
);
}
/****** Reducer *******/
export default function authReducer(state: contextTypes.AuthStateI, action: contextTypes.AuthReducerAction): contextTypes.AuthStateI {
switch (action.type) {
case contextTypes.ActionType.ChangeUserInfo:
return { isAuth: action.payload.isAuth, user: action.payload.user };
default:
return state;
}
}

Components, Pages, and containers

I've put all of the shared components under the components/shared folder, and kept the pages as simple as possible and moved all the forms and complex views to the components page as well.

for the containers, I'm using them for grouping the related paths and pages together, so I can simply apply the protection components against them easily

The main app router, pointing to the container with applying the required protection rules using the protection components:

function App() {
return (
<BrowserRouter>
<MuiThemeProvider>
<AuthProvider>
<Routes>
<Route path="/" element={<GuestRoute><Home /></GuestRoute>} />
<Route path="/auth/*" element={<GuestRoute><AuthContainer /></GuestRoute>} />
<Route path="/dashboard/*" element={<ProtectedRoute><DashboardContainer /></ProtectedRoute>} />
<Route path="/*" element={<GuestRoute><Home /></GuestRoute>} />
</Routes>
</AuthProvider>
</MuiThemeProvider>
</BrowserRouter>
);
}

The containers:

const AuthContainer = () => {
return (
<Routes>
<Route path='/' element={<Dashboard />}> </Route>
<Route path='/newShipment' element={<AllowedForRoute role='customer'><NewShipmentPage/></AllowedForRoute>}> </Route>
<Route path='/waitingShipments' element={<AllowedForRoute role='biker'><WaitingShipmentsPage/></AllowedForRoute>}> </Route>
<Route path='/*' element={<Dashboard />}></Route>
</Routes>
);
};

And lastly the protection components:

  • Guest pages: This helper component will not allow authenticated users from opening/navigating/routing to the routes that are allowed only for guests ( a logged in user for example shouldn't be allowed to navigate to the auth pages ).

    export const GuestRoute: React.FC<{ children: JSX.Element }> = ({ children }) => {
    const { state, dispatch } = useAuth()
    const [loading, setLoading] = useState(true);
    useEffect(() => {
    const token = Cookies.get('authorization');
    if (!state.isAuth && token) {
    const payload = getInfoFromToken(token);
    dispatch(ChangeUserInfo(payload));
    }
    setLoading(false);
    }, [loading]);
    return (
    !loading ? state.isAuth ? <Navigate to="/dashboard" /> : children : <></>
    )
    };

  • Protected pages: This helper component will not allow the guest users from opening/navigating/routing to the pages that require authentication ( dashboard ).

    export const ProtectedRoute: React.FC<{ children: JSX.Element }> = ({ children }) => {
    const { state, dispatch } = useAuth()
    const [loading, setLoading] = useState(true);
    useEffect(() => {
    const token = Cookies.get('authorization');
    if (!state.isAuth && token) {
    const payload = getInfoFromToken(token);
    dispatch(ChangeUserInfo(payload));
    }
    setLoading(false);
    }, [loading]);
    return (
    !loading ? state.isAuth ? children : <Navigate to="/auth/" /> : <></>
    )
    };

  • Allowed for specific roles pages: This helper component will only allow the authorized users to opening/navigating/routing to their specific pages ( a biker shouldn't be allowed to navigate to the customers routes - new shipment route - for example. )

    export const GuestRoute: React.FC<{ children: JSX.Element, role: string }> = ({ children, role }) => {
    const { state } = useAuth()
    const userInfo = state.user as UserInfoI;
    return userInfo.role === role ? children : <Navigate to="/dashboard" />
    };
    export default GuestRoute;

Services

I've created a service layer to manage the communication with the APIs, by creating a base HTTP abstract class, that's extended by each service:

The base HTTP Class:

abstract class BaseService {
private axiosInstance: AxiosInstance
protected abstract routeName: string
constructor() {
this.axiosInstance = axios.create({
baseURL: process.env.REACT_APP_API_LINK
})
}
get http() {
const token = Cookies.get('authorization')
if (token) {
this.axiosInstance.defaults.headers.authorization = "Bearer " + token
}
return this.axiosInstance
}
}

The bikerServices class for example:

class BikerServices extends BaseService {
routeName = '/biker'
async login(Data: LoginBody): Promise<AxiosResponse<LoginResponse>> {
return await this.http.post<LoginResponse>(`${this.routeName}/login`, Data)
}
}

Some Views of the Application

Screen Shot 2022-12-23 at 8 54 53 PM

Screen Shot 2022-12-23 at 5 58 35 PM

Screen Shot 2022-12-23 at 6 18 15 PM

Screen Shot 2022-12-23 at 6 18 37 PM

Screen Shot 2022-12-23 at 6 24 06 PM

Screen Shot 2022-12-23 at 6 23 59 PM

Screen Shot 2022-12-23 at 6 20 37 PM

Screen Shot 2022-12-23 at 6 22 13 PM

Screen Shot 2022-12-23 at 6 25 29 PM

Conclusions

All of the work mentioned above have been done in the past 4 days, I'm pretty sure there're a lot of areas, aspects, and decisions that can be improved. but the time was the main constraint:

  • The most important one of them, is the types, I've used any at many places because having a proper and correct types would time, so at some places, I decided to use any, if I had more time i'd ensure that everything is well typed.

  • I've also planned to add a Continious integration, so the e2e tests can run on each commit / pull request, which's possible due to the fact that we're testing against a mocked database.

  • The error handling and the domain exceptions, as mentioned above.

  • Using class-validator and data-transfer-objects (DTOs) instead of JOI.

  • Adding Swagger documentation for the APIs

  • Moving the errors messages to a single file, instead of having them directly set in-place.

  • Using socket for the shipments status updates, so the biker and the customer can see the changes in real time

  • Focusing in decreasing the large components files, by splitting them into more shared components.

  • I'm not that good at UX, I'm pretty sure the dashboard components is subject to a lot of improvements too.

  • Applying DDD techniques to have the domain fully isolated, for the current implementation, I've only focused into having the use-cases isolated, but in fact, there're no domain objects involved, this's huge to be implemented in 4 days, I've planned to work on it, but then realized that this will not be finished in 4 days, thus decided to relax the complexity and focus on the use-cases. Btw, here's a sketch of the initial plan before relaxing the complexity ( just initial thoughts, not completed and might have some business-defects ).

Untitled-2022-12-19-1817 excalidraw

That's it think. I've really enjoyed working on this, and spent too much time ensuring and caring about the testability and the code quality.



You might also love to take a look on some of the other open-source sample projects that I created recently:


Let's always hope we keep learning, love what we're doing, and more importantly, caring about our users.
If you reached this section, thank you for your time going through it, I hope you enjoyed it. Thank you!