Skip to main content

Chapter 2: Database Services

In the previous chapter, we explored how ShipNowKit's Configuration System acts as a central blueprint for your application. Now, let's turn our attention to another crucial component: Database Services.

Introduction

Imagine you're running a library. You wouldn't let visitors roam freely through the shelves, pulling books out and putting them back wherever they want. Instead, you'd have librarians who know the organization system and can help people find what they need.

Database Services in ShipNowKit work in a similar way. They act as "data librarians" that help your application interact with the database in an organized, consistent manner.

The Problem: Messy Database Access

Without proper database services, your code might access the database directly from many different places:

  • A signup form directly creating user records
  • A payment page directly updating subscription information
  • A profile page directly modifying user data

This approach creates several problems:

  • Code duplication: Similar database operations repeated across files
  • Inconsistent error handling: Different parts of your app might handle database errors differently
  • Difficult maintenance: When your database schema changes, you need to update code in many places

ShipNowKit's Database Services Solution

ShipNowKit solves these problems by organizing database operations into specialized services. Let's see how they work.

Core Database Setup

First, let's look at how ShipNowKit sets up its database connection:

// From db/client.ts
import { PrismaClient } from '@prisma/client'

const globalForPrisma = global as unknown as {
prisma: PrismaClient | undefined
}

export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: [
{ emit: 'event', level: 'query' },
{ emit: 'event', level: 'error' },
{ emit: 'event', level: 'info' },
{ emit: 'event', level: 'warn' },
],
})

This code creates a database client using Prisma (a popular database toolkit). The client is set up to log various events like queries and errors. The globalForPrisma part is a trick to prevent creating multiple database connections during development.

Database Services Structure

ShipNowKit organizes database operations into specialized service files:

  1. user.ts - Functions for user data operations
  2. subscription.ts - Functions for subscription operations
  3. price.ts - Functions for price-related operations
  4. customer.ts - Functions for customer data operations
  5. oneTimePayment.ts - Functions for handling one-time payments

These service files are located in the db/services directory, with each file responsible for handling specific data operations. You can modify these files or add new service files according to your business requirements.

Let's explore a simple example to understand how these services work.

Use Case: Managing User Subscriptions

Imagine we're building a feature where users can subscribe to a premium plan. We need to:

  1. Check if a user already has an active subscription
  2. Create a new subscription if they don't

Step 1: Check for Active Subscriptions

First, let's see how we would check if a user has an active subscription:

// In your application code
import { getUserActiveSubscription } from "@/db/services/user";

async function checkUserSubscription(userId: string, premiumPlanId: string) {
// Use the database service to check for an active subscription
const subscription = await getUserActiveSubscription(userId, premiumPlanId);

if (subscription) {
return "User already has an active subscription!";
} else {
return "User needs to subscribe";
}
}

Here, we're using the getUserActiveSubscription function from our user service rather than writing complex database queries directly. This keeps our code clean and focused on the business logic.

Step 2: Creating a New Subscription

If the user doesn't have a subscription, we might want to create one:

// In your application code
import { upsertSubscription } from "@/db/services/subscription";

async function createUserSubscription(
subscriptionId: string,
userId: string,
priceId: string
) {
// Create a new subscription record
const newSubscription = await upsertSubscription({
id: subscriptionId,
user_id: userId,
price_id: priceId,
status: "active",
provider: "stripe",
current_period_end: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
// Other subscription details...
});

return newSubscription;
}

The upsertSubscription function handles the complexity of either creating a new subscription record or updating an existing one with the same ID.

Example: Inside the User Service

Let's look at how the getUserActiveSubscription function is implemented:

// From db/services/user.ts
export async function getUserActiveSubscription(userId: string, priceId: string) {
try {
return await prisma.subscription.findFirst({
where: {
user_id: userId,
price_id: priceId,
provider: paymentConfig.paymentProvider,
status: {
in: [SubscriptionStatus.active, SubscriptionStatus.trialing]
}
}
});
} catch (error) {
throw handleDatabaseError(error);
}
}

This function:

  1. Takes a user ID and price ID as parameters
  2. Uses Prisma to search for a subscription matching those IDs
  3. Only returns subscriptions with "active" or "trialing" status
  4. Uses a consistent error handling approach

The handleDatabaseError function helps standardize error handling across all database operations:

// From db/utils.ts (simplified example)
export function handleDatabaseError(error: unknown) {
// Log the error
console.error("Database error:", error);

// Return a standardized error
return new Error("Something went wrong with the database operation");
}

This consistent error handling means that database errors are managed the same way throughout your application.

Practical Tips for Using Database Services

Here are some tips for working with ShipNowKit's database services:

1. Import Only What You Need

// Good practice
import { getUserActiveSubscription } from "@/db/services/user";

// Avoid importing everything
// import * from "@/db/services/user";

Importing only the specific functions you need keeps your code cleaner and can help with performance.

2. Use Try-Catch Blocks for Additional Error Handling

async function subscribeUser(userId: string, priceId: string) {
try {
const subscription = await createUserSubscription("sub_123", userId, priceId);
return { success: true, subscription };
} catch (error) {
console.error("Failed to subscribe user:", error);
return { success: false, error: "Subscription failed" };
}
}

While database services handle database-specific errors, your application code should handle higher-level errors and provide appropriate feedback.

3. Combine Multiple Services When Needed

Sometimes you'll need to use multiple database services together:

import { getUserActiveSubscription } from "@/db/services/user";
import { upsertSubscription } from "@/db/services/subscription";
import { getPriceById } from "@/db/services/price";

async function upgradeUserPlan(userId: string, newPriceId: string) {
// First check if the price exists
const price = await getPriceById(newPriceId);
if (!price) {
return { success: false, error: "Invalid price plan" };
}

// Check if user already has this subscription
const existingSub = await getUserActiveSubscription(userId, newPriceId);
if (existingSub) {
return { success: false, error: "Already subscribed to this plan" };
}

// Create the new subscription
const newSubscription = await upsertSubscription({
id: "sub_" + Date.now(),
user_id: userId,
price_id: newPriceId,
status: "active",
// Other details...
});

return { success: true, subscription: newSubscription };
}

By combining services, you can build complex features while keeping your code organized.

Conclusion

Database Services in ShipNowKit act as specialized "data librarians" that provide consistent, organized access to your application's data. By abstracting database operations into services, your application code becomes cleaner, more maintainable, and less prone to errors.

Instead of writing complex database queries directly in your application code, you can use these pre-built services to handle common data operations. This approach helps separate your business logic from data access concerns.

In the next chapter, we'll explore the Theme System, which will help you create beautiful, consistent user interfaces for your application.