Sunday, December 29, 2024
Building Scalable Microservices with Encore.js, TypeScript, and Prisma: A Complete Guide
Posted by
Why Encore.js is the Future of Backend Development
In the ever-evolving landscape of backend development, finding the right framework that balances developer experience with scalability can be challenging. Enter Encore.js: a powerful backend framework that's revolutionizing how we build and deploy microservices.
What is Encore.js?
Open Source TypeScript Backend Framework for robust type-safe applications.
Encore.js is a cloud-native backend development engine that combines the best aspects of modern development practices with cloud infrastructure. It's designed to eliminate boilerplate code while providing built-in development tools, observability, and documentation generation.
Why Consider Encore.js for Your Next Project?
1. Superior Developer Experience
- Zero configuration needed for development environments
- Automatic API documentation generation
- Built-in type safety with TypeScript support
- Integrated development dashboard
2. Cloud-Native Architecture
- Seamless deployment to major cloud providers
- Built-in distributed tracing
- Automatic service discovery
- Infrastructure as code without the complexity
3. Performance Comparison
Rust-powered Performance and Type-Safety in Node.js
- Performance: Multi-threaded request handling and validation in Rust
- Compatibility: Runs as a native Node.js process for full ecosystem compatibility
- Type-Safety: Automatic request validation in Rust for runtime type-safety
Creating a CRUD API with Encore.js, TypeScript, and Prisma
Let's build a production-ready microservice that demonstrates the power of Encore.js. We'll create a contact management API that showcases best practices for building scalable applications.
Prerequisites
- Node.js installed (v14 or later)
- Docker Desktop
- Git
- Basic TypeScript knowledge
Step 0: Install Encore CLI
For windows
iwr https://encore.dev/install.ps1 | iex
Step2 : Create a blank encore app
encore app create my-app-name --example=ts/empty
Step 3: Install Prisma and Prisma client
npm install prisma --save-dev
npm install @prisma/client
npx prisma init
Step 4: Run your Docker Desktop
Step 5: Setting Up Prisma
Update your Prisma schema file with your Model:
prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model Contact {
id String @id @default(uuid())
name String
email String @unique
phone String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Step 6: Creating a Global Prisma Instance
Create database.ts
:
import { SQLDatabase } from "encore.dev/storage/sqldb";
import { PrismaClient } from "@prisma/client";
const DB = new SQLDatabase("contact-db", {
migrations: {
path: "./prisma/migrations",
source: "prisma",
},
});
// Setup prisma client with connection string
const prisma = new PrismaClient({
datasources: {
db: {
url: DB.connectionString,
},
},
});
export { prisma };
Step 7: Generate a Postgress DATABASE URL
- Create a migrations folder inside prisma
- To get the shadow db connection url to Encore.ts shadow database, run:
encore db conn-uri <database name> --shadow
- Update the DB URL in the .env
Step 8: Run the Migrations
npx prisma migrate dev --name init
Running locally
encore run
Run prisma generate command
Create the Service folder structure
- create encore.service.ts // Defines the service
- create contact.interface.ts //Holds the types
- create contact.model.ts //Holds the types from prisma client
- create contact.service.ts // Holds the object of funcs
- create contact.controller.ts // Holds the API
- create utils.ts //Holds the utils
Step 6: Creating the Service Structure
Create the following files in your services directory:
contact.interface.ts
export interface ContactDto {
id: number;
name: string;
phone: string;
}
export interface CreateContactDto {
name: string;
phone: string;
}
export interface UpdateContactDto {
name?: string;
phone?: string;
}
export interface Response {
success: boolean;
message?: string;
result?: string | number;
}
export interface ContactResponse {
/** Indicates if the request was successful */
success: boolean;
/** Error message if the request was not successful */
message?: string;
/** User data */
result?: ContactDto | ContactDto[];
}
contact.service.ts
import { prisma } from "../database";
import {
ContactResponse,
CreateContactDto,
UpdateContactDto,
Response,
} from "./contact.interface";
const ContactService = {
count: async (): Promise<number> => {
const count = await prisma.contact.count();
return count;
},
create: async (data: CreateContactDto): Promise<ContactResponse> => {
const contact = await prisma.contact.create({ data });
return {
success: true,
result: contact,
};
},
update: async (
id: number,
data: UpdateContactDto
): Promise<ContactResponse> => {
const contact = await prisma.contact.findFirst({ where: { id } });
if (!contact) {
return {
success: false,
message: "Contact not found",
};
}
contact.name = data.name || contact.name;
contact.phone = data.phone || contact.phone;
const updated = await prisma.contact.update({
data: contact,
where: { id },
});
return {
success: true,
result: updated,
};
},
findOne: async (id: number): Promise<ContactResponse> => {
const contact = await prisma.contact.findFirst({ where: { id } });
if (!contact) {
return {
success: false,
message: "Contact not found",
};
}
return {
success: true,
result: contact,
};
},
findAll: async (): Promise<ContactResponse> => {
const contacts = await prisma.contact.findMany();
return {
success: true,
result: contacts,
};
},
delete: async (id: number): Promise<Response> => {
const contact = await prisma.contact.findFirst({ where: { id } });
if (!contact) {
return {
success: false,
message: "Contact not found",
};
}
await prisma.contact.delete({ where: { id } });
return {
success: true,
result: "Contact deleted successfully",
};
},
};
export default ContactService;
contact.controller.ts
import { api, APIError } from "encore.dev/api";
import ContactService from "./contact.service";
import {
ContactResponse,
CreateContactDto,
Response,
UpdateContactDto,
} from "./contact.interface";
/**
* Counts and returns the number of existing users
*/
export const count = api(
{ expose: true, method: "GET", path: "/contacts/count" },
async (): Promise<Response> => {
try {
const result = await ContactService.count();
return { success: true, result };
} catch (error) {
throw APIError.aborted(
error?.toString() || "Error counting existing contacts"
);
}
}
);
/**
* Method to create a new contact
*/
export const create = api(
{ expose: true, method: "POST", path: "/contacts" },
async (data: CreateContactDto): Promise<ContactResponse> => {
try {
if (!data.name || !data.phone) {
throw APIError.invalidArgument("Missing fields");
}
const result = await ContactService.create(data);
return result;
} catch (error) {
throw APIError.aborted(error?.toString() || "Error creating the user");
}
}
);
/**
* Get all Contacts data
*/
export const read = api(
{ expose: true, method: "GET", path: "/contacts" },
async (): Promise<ContactResponse> => {
try {
const result = await ContactService.findAll();
return result;
} catch (error) {
throw APIError.aborted(
error?.toString() || "Error getting contacts data"
);
}
}
);
/**
* Get contact data by id
*/
export const readOne = api(
{ expose: true, method: "GET", path: "/contacts/:id" },
async ({ id }: { id: number }): Promise<ContactResponse> => {
try {
const result = await ContactService.findOne(id);
return result;
} catch (error) {
throw APIError.aborted(error?.toString() || "Error getting contact data");
}
}
);
/**
* Update user data
*/
export const update = api(
{ expose: true, method: "PATCH", path: "/contacts/:id" },
async ({
id,
data,
}: {
id: number,
data: UpdateContactDto,
}): Promise<ContactResponse> => {
try {
const result = await ContactService.update(id, data);
return result;
} catch (error) {
throw APIError.aborted(error?.toString() || "Error updating user");
}
}
);
/**
* Delete user by id
*/
export const destroy = api(
{ expose: true, method: "DELETE", path: "/contacts/:id" },
async ({ id }: { id: number }): Promise<Response> => {
try {
const result = await ContactService.delete(id);
return result;
} catch (error) {
throw APIError.aborted(error?.toString() || "Error deleting contact");
}
}
);
contact.model.ts
import { Contact } from "@prisma/client";
export { type Contact };
encore.service.ts
import { Service } from "encore.dev/service";
export default new Service("contacts");
Step 7: Running Migrations
Initialize and run your first migration:
npx prisma migrate dev --name init
Step 8: Running the Application
Start your application locally:
encore run
Visit http://localhost:9400 to access Encore's development dashboard.
Step 9: Deployment
Deploy your application to Encore's development cloud:
git add -A .
git commit -m 'Initial implementation of contact API'
git push encore
Best Practices for Scaling
- Service Boundaries: Keep services focused and independent
- Error Handling: Implement proper error handling and validation
- Monitoring: Utilize Encore's built-in monitoring capabilities
- Testing: Write comprehensive tests for your services
- Documentation: Keep API documentation up-to-date
Conclusion
Encore.js, combined with TypeScript and Prisma, provides a powerful foundation for building scalable microservices. The framework's built-in capabilities significantly reduce development time while ensuring best practices are followed.
Next Steps
- Explore Encore's advanced features like authentication and caching
- Add more complex business logic to your services
- Implement comprehensive testing
- Set up continuous deployment
Ready to take your backend development to the next level? Start building with Encore.js today!
Remember to check out the official Encore documentation for more advanced features and best practices.