DEV Community

Cover image for Building A Wallet System - Part 1: Authentication with Apollo Server
Kiishi_joseph
Kiishi_joseph

Posted on

Building A Wallet System - Part 1: Authentication with Apollo Server

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
Enter fullscreen mode Exit fullscreen mode

๐Ÿงฌ 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
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

๐Ÿง  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" });
}
Enter fullscreen mode Exit fullscreen mode

๐Ÿงพ 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 });
  }
}
Enter fullscreen mode Exit fullscreen mode

๐Ÿงฉ 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);
  }
}
Enter fullscreen mode Exit fullscreen mode

๐Ÿง  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;
}
Enter fullscreen mode Exit fullscreen mode

๐Ÿง‘โ€๐Ÿ’ป 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);
  }
}
Enter fullscreen mode Exit fullscreen mode

๐Ÿ“ฆ 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];
}
Enter fullscreen mode Exit fullscreen mode

๐Ÿš€ 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();
Enter fullscreen mode Exit fullscreen mode

โœ… 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)

Collapse
 
abdulrasheed_iyanda_95406 profile image
Abdulrasheed Iyanda

great piece!!

Collapse
 
vectorware profile image
Akalonu Chukwuduzie Blaise

Real nice article, I am a bit curious what are the benefits of the repository service architecture when compared to other patterns.

Collapse
 
adeoluwa profile image
Kiishi_joseph

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.

Collapse
 
vectorware profile image
Akalonu Chukwuduzie Blaise

Nice, thank you. I will be waiting for part 2

Thread Thread
 
adeoluwa profile image
Kiishi_joseph