6/6/2024

#software-development#tutorials#node.js#architecture

Layered Architecture: Implementing a Backend with fastify

The layered architecture pattern is very popular, simple to implement, and offers numerous benefits but also some drawbacks. In this post, we will go over a practical example of using it to implement a simple Node.js application with fastify and Prisma.

The Layered Architecture Pattern

This architecture is highly common due to its alignment with traditional IT team structures. It often consists of four layers: presentation, business, persistence, and database. Sometimes, the persistence and business logic layers may be combined. This pattern allows for a strong separation of concerns, and its layers can be either open or closed. Closed layers cannot be skipped and provide a layer of isolation, meaning changes made to one layer do not affect other layers, except for adjacent ones. Open layers, on the other hand, may be used for services that are optional. Proper documentation of open and closed layers helps prevent tightly coupled architectures.

An important consideration is the architecture sinkhole antipattern, where requests pass through the layers without any processing. If most requests pass through layers without processing, it might be beneficial to use open layers. This architecture is a good choice if a project has significant budget or time constraints, if most changes are constrained to one layer, and if the teams are technically partitioned. According to Conway's Law, organizations design systems that mirror their own communication structure. However, this architecture may not be ideal if the project has high operational concerns requiring scalability, elasticity, fault tolerance, and performance. It is also not suitable if most changes occur at the domain level or if the team structure is cross-functional and domain-based.

A Common Use Case: Web Applications

What better way to fully understand a pattern than to see it in action? We will build a small Node.js backend application that exposes a simple HTTP interface. We will use Fastify as the HTTP server, Prisma ORM as the object-relational mapping (ORM), and SQLite as the database.

The Use Cases

Our backend will implement the business logic for a naive election voting system. We have three use cases:

  1. Getting a list of available candidates (we assume there is only one election).
  2. Submitting a vote by providing a selected candidate (we assume that everyone only votes once and can be trusted 100%).
  3. Getting the latest election results.

The Layers

We will have four closed layers. The HTTP interface will serve as our presentation layer and consist of a set of route handlers that receive and respond to incoming HTTP requests. The route handlers will depend on the ElectionService, which serves as our business logic layer. Persistence of votes (candidates are fixed) will be handled by the VotesPool, which thus represents our persistence layer. Finally, a simple SQLite database will serve as our database layer.

The Business Layer

You should always start at the business layer as it represents the core of your application and should be protected from outgoing dependencies as much as possible. Use dependency inversion through the definition of interfaces to prevent dependencies on lower-level layers.

Defining the Service

To keep things short our business layer will consist of only one service: The ElectionService, which we define as follows:

interface ElectionService {
    getAvailableCandidates(): Candidate[]

    submitVote(vote: Vote): Promise<VotingConfirmation>

    getElectionResults(): Promise<ElectionResults>
}

type Candidate = {
    id: string
    name: string
}

type Vote = {
    candidateId: string
}

type VotingConfirmation = {
    candidateId: string
    success: boolean
    reasonForFailure?: string 
}

type ElectionResults = {
    [candidateName: string]: number
}

Next we define a class DefaultElectionService that will implement the interface, which however will not have any behaviour yet as we will first define some test cases for it.

class DefaultElectionService implements ElectionService {
    constructor(private votesPool: VotesPool) { }

    getAvailableCandidates(): Candidate[] {
        throw new Error("Not implemented")
    }

    async submitVote(vote: Vote): Promise<VotingConfirmation> {
        throw new Error("Not implemented")
    }

    async getElectionResults(): Promise<ElectionResults> {
        throw new Error("Not implemented")
    }
}

interface VotesPool {
    createVote(vote: Vote): Promise<void>

    listVotes(): Promise<Vote[]>
}

As we know that our DefaultElectionService will require persistence we also already defined a VotesPool interface that we will implement later on.

Implementing the Service

To save us some stress and make sure that we are implementing all the interfaces correctly we will use Test-driven development (TDD). We thus define a couple of test cases that describe our ElectionService's expected behaviour, using Mocha, Chai.js and a mock implementation of the VotesPool interface:

class MockVotesPool implements VotesPool {
    createVoteError?: string
    votes: Vote[] = []

    createVote(vote: Vote): Promise<void> {
        return this.createVoteError ? Promise.reject(this.createVoteError) : Promise.resolve()
    }

    listVotes(): Promise<Vote[]> {
        return Promise.resolve(this.votes)
    }
}

describe("ElectionService", function () {
    let electionService: ElectionService
    let votesPool: MockVotesPool

    beforeEach(function() {
        votesPool = new MockVotesPool()
        electionService = new DefaultElectionService(votesPool)
    })

    it("Should get the correct list of candidates", function () {
        const expectedCandidates: Candidates[] = [
            {
                id: "0",
                name: "Carla"
            },
            {
                id: "1",
                name: "Peter"
            },
            {
                id: "2",
                name: "Lisa"
            },
            {
                id: "3",
                name: "Richard"
            }
        ]
        const actualCandidates = electionService.getAvailableCandidates()
        expect(actualCandidates).to.have.same.members(expectedCandidates)
    })

    it("Should provide a positive confirmation upon successful submission of a valid vote", async function() {
        const vote: Vote = {
            candidateId: "0"
        }
        const expectedConfirmation: VotingConfirmation = {
            candidateId: "0",
            success: true
        }
        const actualConfirmation = await electionService.submitVote(vote)
        expect(actualConfirmation).to.deep.equal(expectedConfirmation)
    })

    it("Should provide a negative confirmation upon submission of a vote for an unknown candidate", async function() {
        const vote: Vote = {
            candidateId: "10"
        }
        const expectedConfirmation: VotingConfirmation = {
            candidateId: "10",
            success: false,
            reasonForFailure: "Unknown candidate"
        }
        const actualConfirmation = await electionService.submitVote(vote)
        expect(actualConfirmation).to.deep.equal(expectedConfirmation)
    })

    it("Should provide a negative confirmation upon failed submission of a valid vote", async function() {
        votesPool.createVoteError = "Database connection timeout"
        const vote: Vote = {
            candidateId: "0"
        }
        const expectedConfirmation: VotingConfirmation = {
            candidateId: "0",
            success: false,
            reasonForFailure: votesPool.createVoteError
        }
        const actualConfirmation = await electionService.submitVote(vote)
        expect(actualConfirmation).to.deep.equal(expectedConfirmation)
    })

    it("Should return the correct election results based on the submitted votes", async function() {
        votesPool.votes = [
            ["0", 5],
            ["1", 8],
            ["2",13]
        ].map(([candidateId, count]) => Array(count).map(() => ({
            candidateId,
        }))).flat()

        const expectedElectionResults: ElectionResults = {
            "Carla": 5,
            "Peter": 8,
            "Lisa": 13,
            "Richard": 0
        }
        const actualElectionResults = await electionService.getElectionResults()
        expect(actualElectionResults).to.deep.equal(expectedElectionResults)
    })
})

Based on our test cases we then arrive at the following implementation of DefaultElectionService that passes all the tests:

class DefaultElectionService {
    constructor(private votesPool: VotesPool) { }

    getAvailableCandidates(): Candidate[] {
        return [
            {
                id: "0",
                name: "Carla"
            },
            {
                id: "1",
                name: "Peter"
            },
            {
                id: "2",
                name: "Lisa"
            },
            {
                id: "3",
                name: "Richard"
            }
        ]
    }

    async submitVote(vote: Vote): Promise<VotingConfirmation> {
        const validCandidateIds = this.getAvailableCandidates().map((c) => c.id)
        if (!validCandidateIds.contains(vote.candidateId)) {
            return Promise.reject("Unknown candidate")
        }

        try {
            await this.votesPool.createVote(vote)
            return {
                candidateId: vote.candidateId,
                success: true
            }
        } catch (err) {
            if (typeof err === "string") {
                return {
                    candidateId: vote.candidateId,
                    success: false,
                    reasonForFailure: err
                }
            } else {
                return {
                    candidateId: vote.candidateId,
                    success: false,
                    reasonForFailure: "Internal error"
                }
            }
        }
    }

    async getElectionResults(): Promise<ElectionResults> {
        const votes = await this.votesPool.listVotes()
        const candidateIdToNameMap = new Map(this.getAvailableCandidates().map((c) => [c.id, c.name]))

        const electionResults: ElectionResults = { }
        for (const vote of votes) {
            const candidateName = candidateIdToNameMap.get(vote.candidateId) ?? "unknown"
            if (Object.hasOwn(electionResults, candidateName)) {
                electionResults[candidateName] += 1
            } else {
                electionResults[candidateName] = 1
            }
        }
        return electionResults
    }
}

Taking Shortcuts

As we want to perform user testing of our business logic as soon as possible, we want to have a working prototype as early as possible. Because our business layer is agnostic to the implementation of the persistence layer we can first use in-memory storage, refine our business layer and then properly implement the persistence and database layers afterwards. In order to provide in-memory storage we simply define the class InMemoryVotesPool that will implement the VotesPool interface:

class InMemoryVotesPool implements VotesPool {
    createVote(vote: Vote): Promise<void> {
        throw new Error("Not implemented")
    }

    listVotes(): Promise<Vote[]> {
        throw new Error("Not implemented")
    }
}

In the spirit of TDD we first define a set of test cases that define the expected behaviour of the VotesPool before we start any implementation:

describe("VotesPool", function() {
    let votesPool: VotesPool

    beforeEach(function() {
        votesPool = new InMemoryVotesPool()
    })

    it("Should allow to create votes", async function() {
        const vote: Vote = {
            candidateId: "0"
        }
        await votesPool.createVote(vote)
    })

    it("Should return an empty list if there are no votes", async function() {
        const actualVotes = await votesPool.listVotes()
        expect(actualVotes).to.be.empty
    })

    it("Should create votes and allow to get them afterwards", async function() {
        const expectedVotes: Vote[] = ["0", "1", "2", "3"].map((id) => ({
            candidateId: id
        }))
        for (const vote of expectedVotes) {
            await votesPool.createVote(vote)
        }
        const actualVotes = await votesPool.listVotes()
        expect(actualVotes).to.have.deep.members(expectedVotes)
    })
})

Based on our test cases we arrive at the following simple implementation of InMemoryVotesPool that passes all the tests:

class InMemoryVotesPool implements VotesPool {
    private votes: Vote[] = []

    createVote(vote: Vote): Promise<void> {
        this.votes.push(vote)
    }

    listVotes(): Promise<Vote[]> {
        //Deep clone the list to prevent manipulation
        return this.votes.map((vote) => ({ ...vote }))
    }
}

Now that we have a simple persistence layer we can implement a working presentation layer in the form of an HTTP interface for our application.

Implementing the Presentation Layer

As mentioned earlier, our application will only offer an HTTP interface as a means of presentation. To build such we will use the fastify framework to define an HTTP server. Because we want to be able to use TDD again we need to define a simple factory function for our fastify server in order to make use of fastify's inject feature.

function buildServerInstance(
    logger: boolean,
    electionService: ElectionService
): FastifyInstance {
    const server = Fastify({
        logger
    })
    //Where we will add our routes and inject the service
    return server
}

Next we define a set of test cases for our server by using the inject feature of fastify and a mock implementation of our ElectionService:

class MockElectionService implements ElectionService {
    availableCandidates!: Candidate[]
    votingConfirmation!: VotingConfirmation
    electionResults!: ElectionResults

    getAvailableCandidates(): Candidate[] {
        return this.availableCandidates
    }

    async submitVote(vote: Vote): Promise<VotingConfirmation> {
        return Promise.resolve(this.votingConfirmation)
    }

    async getElectionResults(): Promise<ElectionResults> {
        return Promise.resolve(this.electionResults)
    }
}

describe("Fastify Server", async function() {
    let server: FastifyInstance
    let mockElectionService: MockElectionService

    beforeEach(function() {
        mockElectionService = new MockElectionService()
        server = buildServerInstance(true, mockElectionService)
    })

    it("Should get a list of available candidates through GET /candidates", async function() {
        const expectedCandidates = [
            {
                id: "0",
                name: "Laura Peters"
            },
            {
                id: "1",
                name: "Michael Meyers"
            }
        ]
        mockElectionService.availableCandidates = expectedCandidates
        const response = await server.inject({
            method: 'GET',
            url: '/candidates'
        })
        const statusCode = response.statusCode
        const actualCandidates = response.body.json()
        expect(statusCode).to.equal(200)
        expect(actualCandidates).to.be.an.array
        expect(actualCandidates).to.have.length(expectedCandidates.length)
        expect(actualCandidates).to.have.deep.members(expectedCandidates)
    })

    it("Should allow to successfuly submit a vote through POST /votes", async function() {
        const expectedConfirmation = {
            success: true,
            candidateId: "0"
        }
        mockElectionService.votingConfirmation = expectedConfirmation
        const payload = {
            candidateId: "0"
        }
        const response = await server.inject({
            method: 'POST',
            url: '/votes',
            payload
        })
        const statusCode = response.statusCode
        const actualConfirmation = response.body.json()
        expect(statusCode).to.equal(201)
        expect(actualConfirmation).to.deep.equal(expectedConfirmation)
    })

    it("Should inform the sender if they failed to submit a valid vote through POST /votes", async function() {
        const expectedConfirmation = {
            success: false,
            candidateId: "0",
            reasonForFailure: "Ballots are closed"
        }
        mockElectionService.votingConfirmation = expectedConfirmation
        const payload = {
            candidateId: "0"
        }
        const response = await server.inject({
            method: 'POST',
            url: '/votes',
            payload
        })
        const statusCode = response.statusCode
        const actualConfirmation = response.body.json()
        expect(statusCode).to.equal(422)
        expect(actualConfirmation).to.deep.equal(expectedConfirmation)
    })

    it("Should inform the sender if they sent a malformed request to POST /votes", async function() {
        const payload = {
            candidate: "0"
        }
        const response = await server.inject({
            method: 'POST',
            url: '/votes',
            payload
        })
        const statusCode = response.statusCode
        const errorResponse = response.body.json()
        expect(statusCode).to.equal(400)
        expect(errorResponse.error).to.equal("Missing required property 'candidateId'")
    })

    it("Should get the latest election results through POST /results", async function() {
        const expectedResults = {
            "Laura Palmer": 15
            "Peter Meyer": 14
        }
        mockElectionService.electionResults = expectedResults
        const response = await server.inject({
            method: 'GET',
            url: '/results'
        })
        const statusCode = response.statusCode
        const actualResults = response.body.json()
        expect(statusCode).to.equal(200)
        expect(actualResults).to.deep.equal(expectedResults)
    })
})

Based on these test cases we end up with the following updated implementation of our server factory:

function buildServerInstance(
    logger: boolean,
    electionService: ElectionService
): FastifyInstance {
    const server = Fastify({
        logger
    })

    server.get('/candidates', function (request, reply) {
        const candidates = electionService.getAvailableCandidates()
        reply.status(200).send(candidates)
    })

    server.post('/votes', async function (request, reply) {
        const payload = request.body
        if (candidateId in payload) {
            const vote = {
                candidateId: payload.candidateId
            }
            const confirmation = await electionService.submitVote(vote)
            const status = confirmation.success ? 201 : 422
            reply.status(status).send(confirmation)
        } else {
            reply.status(400).send({
                error: "Missing required property 'candidateId'"
            })
        }
    })

    server.get('/results', async function (request, reply) {
        const results = await electionService.getElectionResults()
        reply.status(200).send(results)
    })

    return server
}

We can now deploy a first version of our server and see if our application satisfies our envisioned user experience. To this end we add some code to boot our server in a server.ts file

const server = buildServerInstance(
    true,
    new DefaultElectionService(new InMemoryVotesPool())
)
server.listen({ port: 3000 }, (err, address) => {
  if (err) {
    server.log.error(err)
    process.exit(1)
  }
})

Then we can simply run it using tsc -p tsconfig.json && node dist/server.js. After playing around with the HTTP interface and (hopefully) concluding that it is usable in the current form we decide to go ahead with the final implementation of the persistence and database layers.

Implementing the Persistence Layer

Depending on your used database and requirements you might either decide to write your own logic for interfacing with the database or make use of an out-of-the-box solution like Prisma ORM. As our application requires only very simple database queries and we want to save some time we will use Prisma ORM as implementation of our database layer and even use it to manage our database schema.

Defining the Database Schema

Prisma ORM allows us to define the database schema in a schema.prisma file and use it to generate migrations for a multitude of supported databases (SQLite is one of them). Based on our VotesPool we arrive at the following simple schema for an SQLite database:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}
model votes {
  id            Int     @id @default(autoincrement())
  candidateId   String
}

Let's make sure that the schema is valid and can be used to create a SQLite database by running

npx prisma migrate dev --name init

This will create a migrations folder and a dev.db file under the prisma directory and generate a Prisma Client API that we can implement against based on our schema. Prisma ORM thus just created the database layer for us in a few steps as the generated Client API takes care of all the interfacing with the database.

Implementing the PrismaVotesPool

Now that we have our database layer in place we can easily implement our persistence layer using the Prisma Client API. To this end we define an empty class PrismaVotesPool that will implement the VotesPool interface:

class PrismaVotesPool implements VotesPool {
    createVote(vote: Vote): Promise<void> {
        throw new Error("Not implemented")
    }

    listVotes(): Promise<Vote[]> {
        throw new Error("Not implemented")
    }
}

And use it to replace our InMemoryVotesPool class in the "VotesPool" test suite:

    beforeEach(function() {
        votesPool = new PrismaVotesPool()
    })

Now all our tests for the VotesPool will fail again until we come up with the following implementation of the PrismaVotesPool:

class PrismaVotesPool implements VotesPool {
    private prisma: PrismaClient

    constructor(){
        this.prisma = new PrismaClient()
    }

    async createVote(vote: Vote): Promise<void> {
        await this.prisma.votes.create({
            data: {
                candidateId: vote.candidateId,
            },
        });
    }

    async listVotes(): Promise<Vote[]> {
        const votes = await this.prisma.votes.findMany();
        return votes.map((vote) => ({
            candidateId: vote.candidateId,
        }));
    }
}

With all the tests passing again we can now use our final implementation of the persistence layer when launching our fastify application:

const server = buildServerInstance(
    true,
    new DefaultElectionService(new PrismaVotesPool())
)
...

With all parts in place we can now run our final application again and check if it offers true persistence and still offers the responsiveness we demand (ideally we would also ensure that by writing performance and E2E tests).

Taking a Step Back

By implementing our application using a layered architecture, we saw how this simple pattern can offer us a clear separation of concerns, facilitating maintainability and scalability. Throughout this process, we leveraged test-driven development to ensure our code met the desired functionality at every layer. Moving forward, this foundation allows us to easily extend functionality, optimize performance, and adapt to changing requirements, making it a robust choice for many web applications!