Hey folks! ๐
This is Part 1 of a blog series where I document how Iโm building CashCove โ a simplified wallet backend. Iโm using GraphQL, Apollo Server, Prisma, and a clean repository-service architecture.
๐ GitHub Repo: https://github.com/adeoluwa/CashCove
๐ Note: This guide assumes you're already comfortable setting up a Node.js project with TypeScript and Prisma. If not, check out the Prisma Getting Started Guide first.
Also, I noticed the back cover image looks cool ๐
๐ฏ Why GraphQL? (Even as a First-Time User)
This is my first proper project using GraphQL, and I picked it because:
- I wanted to try something new beyond REST.
- I love how GraphQL gives the client control over the data it wants.
- Apollo Server + TypeGraphQL makes schema management really smooth.
So this blog is also a learning journal.
๐งฑ Tech Stack Overview
- Apollo Server for the GraphQL API
- Prisma ORM with SQLite
- TypeGraphQL for decorator-based schema generation
- Repository-Service Pattern for scalable logic and clean separation of concerns
๐๏ธ Project Structure
CashCove/
โโโ src/
โ โโโ resolvers/ # GraphQL resolvers (auto-loaded)
โ โโโ services/ # Business logic lives here
โ โโโ repositories/ # All Prisma DB operations
โ โโโ utils/ # Auth utils, helpers
โ โโโ middleware/ # Auth & error handling
โ โโโ loaders/ # Resolver loader
โ โโโ types/ # Custom context types, etc.
โ โโโ index.ts # App entry point
โ โโโ prisma.ts # Singleton Prisma client
โโโ schema.prisma # Prisma schema
๐งฌ Prisma User Model
For this part, we only focus on the User
model (simplified for authentication):
model User {
id String @id @default(uuid())
email String @unique
password String
account_number String
address String?
phone_number String?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
state UserState @default(ACTIVE)
@@map("users")
}
enum UserState {
ACTIVE
SUSPENDED
}
We're using SQLite for local development, but Prisma makes it easy to switch providers later.
๐ ๏ธ Prisma Singleton
To prevent multiple Prisma connections, we use a singleton class:
// src/prisma.ts
import { PrismaClient } from "@prisma/client";
export class PrismaSingleton {
private static instance: PrismaClient;
public static getInstance(): PrismaClient {
if (!PrismaSingleton.instance) {
PrismaSingleton.instance = new PrismaClient();
}
return PrismaSingleton.instance;
}
}
const prisma = PrismaSingleton.getInstance();
export default prisma;
๐ง Auth Utilities
We hash passwords and generate tokens:
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
dotenv.config();
export async function hashPassword(password: string) {
return await bcrypt.hash(password, 10);
}
export async function verifyPassword(password: string, hashed: string) {
return await bcrypt.compare(password, hashed);
}
export function generateToken(userId: string) {
return jwt.sign({ userId }, process.env.JWT_SECRET!, { expiresIn: "1h" });
}
๐งพ User Repository
// src/repositories/user.repository.ts
import prisma from "../prisma";
export class UserRepository {
async findUserByEmail(email: string) {
return prisma.user.findUnique({ where: { email } });
}
async findUserById(id: string) {
return prisma.user.findUnique({ where: { id } });
}
async createUser(email: string, password: string, accountNumber: string) {
return prisma.user.create({
data: {
email,
password,
account_number: accountNumber,
},
});
}
async updateUserProfile(id: string, updates: any) {
return prisma.user.update({ where: { id }, data: updates });
}
}
๐งฉ User Service
This class handles logic like validation, hashing, and normalizing data:
import { UserRepository } from "../repositories/user.repository";
import { hashPassword, verifyPassword } from "../utils/auth";
import isEmail from "validator/lib/isEmail";
export class UserService {
constructor(private userRepository = new UserRepository()) {}
private generateAccountNumber() {
return Math.floor(1000000000 + Math.random() * 9000000000).toString();
}
private normalizeEmail(email: string) {
return email.trim().toLowerCase();
}
async register(email: string, password: string) {
const normalizedEmail = this.normalizeEmail(email);
if (!isEmail(normalizedEmail)) throw new Error("Invalid email format");
const existing = await this.userRepository.findUserByEmail(normalizedEmail);
if (existing) throw new Error("Account already in use!");
const hashedPassword = await hashPassword(password);
return this.userRepository.createUser(
normalizedEmail,
hashedPassword,
this.generateAccountNumber()
);
}
async login(email: string, password: string) {
const normalizedEmail = this.normalizeEmail(email);
const user = await this.userRepository.findUserByEmail(normalizedEmail);
if (!user) throw new Error("User not found");
if (!(await verifyPassword(password, user.password)))
throw new Error("Invalid credentials");
return user;
}
async updateUsersProfile(
id: string,
updates: Partial<{ address: string; phone_number: string }>
) {
if ("email" in updates || "account_number" in updates)
throw new Error("Email and account number cannot be updated");
const user = await this.userRepository.findUserById(id);
if (!user) throw new Error("User not found");
return this.userRepository.updateUserProfile(id, updates);
}
}
๐ง User Schema
The schema defines how data enters and exits the application.
import { Field, InputType, ObjectType } from "type-graphql";
@ObjectType()
export class User {
@Field() id!: string;
@Field() email!: string;
@Field() account_number!: string;
@Field(() => String, { nullable: true }) address?: string | null;
@Field(() => String, { nullable: true }) phone_number?: string | null;
@Field(() => Date, { nullable: true }) created_at?: Date;
}
@InputType()
export class RegisterInput {
@Field() email!: string;
@Field() password!: string;
}
@InputType()
export class LoginInput {
@Field() email!: string;
@Field() password!: string;
}
@InputType()
export class UpdateProfileInput {
@Field({ nullable: true }) address?: string;
@Field({ nullable: true }) phone_number?: string;
}
@ObjectType()
export class LoginResponse {
@Field() token!: string;
@Field(() => User, { nullable: true }) user!: User | null;
}
๐งโ๐ป User Resolver
In GraphQL APIs, resolvers serve the same fundamental purpose as controllers do in REST APIs - they act as the primary entry points that handle incoming requests, orchestrate business logic, and return responses.
import { Arg, Mutation, Query, Resolver } from "type-graphql";
import { UserService } from "../services/user.service";
import {
RegisterInput,
LoginInput,
User,
UpdateProfileInput,
LoginResponse,
} from "../schemas/user.schema";
import { generateToken } from "../utils/auth";
import { CurrentUser } from "../middleware/currentUser";
const userService = new UserService();
@Resolver()
export default class UserResolver {
@Query(() => String)
hello() {
return "Hello, there!";
}
@Mutation(() => User)
async register(@Arg("input") input: RegisterInput): Promise<User> {
return await userService.register(input.email, input.password);
}
@Mutation(() => LoginResponse)
async login(@Arg("input") input: LoginInput): Promise<LoginResponse> {
const user = await userService.login(input.email, input.password);
const token = generateToken(user.id);
return {
token,
user: {
id: user.id,
email: user.email,
account_number: user.account_number,
phone_number: user.phone_number,
address: user.address,
},
};
}
@Mutation(() => User)
async updateProfile(
@CurrentUser() userId: string,
@Arg("input") input: UpdateProfileInput
): Promise<User> {
return await userService.updateUsersProfile(userId, input);
}
}
๐ฆ Auto-Loading Resolvers
In a GraphQL server setup using TypeGraphQL, all resolvers must be registered in the schema during initialization. Instead of manually importing and registering each resolver in index.ts, we automate this process using a loadResolvers.ts utility. This greatly reduces boilerplate and ensures scalability as the number of resolvers grows.
Here's how it works
// src/loaders/loadResolvers.ts
import path from "path";
import fs from "fs";
//import { Resolver, Query } from "type-graphql";
export default async function loadResolvers(): Promise<Function[]> {
const dir = path.join(__dirname, "../resolvers");
const files = fs.readdirSync(dir).filter((f) => f.endsWith("resolver.ts"));
const resolvers = await Promise.all(
files.map(async (file) => {
const mod = await import(path.join(dir, file));
return mod.default;
})
);
return resolvers.length? resolvers : [DefaultResolver];
}
๐ Apollo Server + Express Bootstrapping
This is the entry point of the application. In GraphQL, there is typically a single endpoint that handles all queries and mutations, commonly defined at the /graphql route.
// src/index.ts
import "reflect-metadata";
import express from "express";
import cors from "cors";
import bodyParser from "body-parser";
import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@apollo/server/express4";
import { buildSchema } from "type-graphql";
import loadResolvers from "./loaders/loadResolvers";
async function bootstrap() {
const resolvers = await loadResolvers();
const schema = await buildSchema({
resolvers: resolvers as [Function, ...Function[]],
validate: false,
authChecker: ({ context }) => !!context.user,
});
const server = new ApolloServer({ schema });
const app = express();
await server.start();
app.use(
"/graphql",
cors(),
bodyParser.json(),
expressMiddleware(server, {
context: async ({ req }) => ({ req }),
})
);
app.listen(4000, () => {
console.log("๐ Server running at http://localhost:4000/graphql");
});
}
bootstrap();
โ Summary
In Part 1, we:
- Defined a User model with Prisma
- Set up a repository-service architecture
- Built register/login logic
- Defined GraphQL schema inputs & outputs
- Implemented a full resolver
- Configured Apollo Server
๐ฎ Coming in Part 2: Wallets and Transactions
We'll implement:
- Wallet creation
- Transfers between wallets
- Transaction history
Follow the repo CashCove and stay tuned!
Thanks for reading ๐
Top comments (5)
great piece!!
Real nice article, I am a bit curious what are the benefits of the repository service architecture when compared to other patterns.
It ultimately comes down to project size and personal preference, but for anything beyond simple CRUD operations, this structure makes sense. I prefer the Repository-Service pattern because it keeps my code clean and organized. The controller handles just HTTP logic, the service manages business rules, and the repository deals with the database. Itโs flexible too โ if I switch from Postgres to MongoDB, I only need to update the repo.
Nice, thank you. I will be waiting for part 2
Part 2 is ready:
dev.to/adeoluwa/building-cashcove-...