Service
Service handles all the business logic. For us it's a bit complicated.
For each service, we now have 4 files, take user service as an example,
user.service.tsuser.service.impl.tsuser.service.prisma.impl.tsuser.service.redis.impl.ts
Some of them are in fact repositories, but I call them service as they are implemented together.
user.service.ts defines all the abstraction.
For example, this is how it looks like
interface UserServiceBase {
findByEmail(email: string): Promise<User>;
}
interface UserServiceCache {
/**
* Delete cache keys
* @param keys keys to remove
* @returns number of keys removed
*/
deleteKeys(keys: string | string[]): Promise<number>;
}
interface UserServicePersist extends UserServiceBase {}
interface UserService extends UserServiceBase {
// Auth Services
login(email: string, password: string): Promise<User>;
}
I defined 4 interfaces in user.service.ts because there are all kinds of services, some service responsible for accessing data from persistent and cache databases (they are called repository), some handles business logic.
UserServiceBasedefines all the methods shared between repository and regular service. Some methods are required in both regular service and repository.UserServiceCacheandUserServicePersistdefines the methods a data repository will need to implement. You can understand them as repository pattern, or high-level DAO.UserServiceis the real service class, implementing all the complex business logic.
Here is how these interfaces are used, implemented and worked together.
- Redis Repository
- Prisma Repository
- Service Implementation
class UserServiceRedisImpl implements UserServiceCache {
redis: Redis;
constructor(redis: Redis) {
this.redis = redis;
}
deleteKeys(keys: string | string[]): Promise<number> {
return this.redis.client.del(keys);
}
findByEmail(email: string): Promise<User> {
const key = constructEmailUserRedisKey(email);
return this.redis.client.get(key).then((userId) => {
if (userId)
return this.redis.client
.expire(key, redisShortTTL)
.then(() => this.findById(parseInt(userId)));
return Promise.reject(
`${constructEmailUserRedisKey(
email
)} not found in redis, could be expired or doesn't exist`
);
});
}
}
class UserServicePrismaImpl implements UserServicePersist {
prisma: PrismaClient;
constructor(prisma: PrismaClient) {
this.prisma = prisma;
}
findByEmail = (email: string) =>
this.prisma.user
.findUniqueOrThrow({ where: { email } })
.then((user) => user)
.then((user) => User.parse(user));
}
class UserServiceImpl implements UserService {
persistImpl: UserServicePersist;
cacheImpl: UserServiceCache;
constructor(persistImpl: UserServicePersist, cacheImpl: UserServiceCache) {
this.persistImpl = persistImpl;
this.cacheImpl = cacheImpl;
}
findByEmail(email: string): Promise<User> {
return this.cacheImpl
.findByEmail(email)
.catch(() => this.persistImpl.findByEmail(email))
.then((user) => {
return this.cacheImpl.setUser(user).then(() => user);
});
}
login(email: string, password: string) {
return this.findByEmail(email).then((user) => {
if (!user) throw new UserNotFoundError();
return this.verifyPassword(password, user.password)
.then((valid) => {
if (!valid) throw new Error('Failed to Authenticate: Wrong Password');
return Promise.all([
this.cacheImpl.setUser(user),
this.cacheImpl.setExpireById(user.id, 100000),
]);
})
.then(() => {
return user;
});
});
}
}
- All interface share the same
UserBaseServiceinterface, thus all have to implementfindByEmail. UserServiceRedisImplandUserServicePrismaImplhas their own database-specific implementation.findByEmailinUserServiceofuser.service.impl.tsaggregates the 2 repository services.- A
UserServiceobject is instantiated with a prisma and a cache repository service object. - It tries to use cache service first. If nothing is retrieved, it will try to read the persistent database.
- A
Read System Design Primer to understand cache better.
Finally, we also implemented a factory method to create services more easily.
export function getServices(redis: Redis, prisma: PrismaClient) {
const userService = new UserServiceImpl(
new UserServicePrismaImpl(prisma),
new UserServiceRedisImpl(redis)
);
return {
userService,
};
}
In the factory method, redis and prisma client are passed in. Prisma and Redis repo service and instantiated and passed to the constructor of the main user service.
Remember that the main user service implementation's constructor takes in UserServicePersist and UserServiceCache rather than UserServicePrismaImpl and UserServiceRedisImpl.
class UserServiceImpl implements UserService {
constructor(persistImpl: UserServicePersist, cacheImpl: UserServiceCache) {
this.persistImpl = persistImpl;
this.cacheImpl = cacheImpl;
}
}
In the future, if we want to replace prisma with another DAO library, or redis with another cache database, all we need to do is
- Update the factory method (simple)
- Implemented the new repository classes (inevitable work)
There is no need to update the UserServiceImpl which contains the business logic. This follows Single Responsibility principle, and makes the code easier to maintain and less likely to have bugs.