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