The Service Layer
The service layer is the core of the application. This is where the business logic is implemented, and where the application interacts with the database. When a request comes in to the API, it gets routed to a resolver which then calls a service method to perform the required operation.
Services are classes which, in NestJS terms, are providers. They follow all the rules of NestJS providers, including dependency injection, scoping, etc.
Services are generally scoped to a specific domain or entity. For instance, in the Vendure core, there is a Product
entity,
and a corresponding ProductService
which contains all the methods for interacting with products.
Here's a simplified example of a ProductService
, including an implementation of the findOne()
method that was
used in the example in the previous section:
import { Injectable } from '@nestjs/common';
import { IsNull } from 'typeorm';
import { ID, Product, RequestContext, TransactionalConnection, TranslatorService } from '@vendure/core';
@Injectable()
export class ProductService {
constructor(private connection: TransactionalConnection,
private translator: TranslatorService){}
/**
* @description
* Returns a Product with the given id, or undefined if not found.
*/
async findOne(ctx: RequestContext, productId: ID): Promise<Product | undefined> {
const product = await this.connection.findOneInChannel(ctx, Product, productId, ctx.channelId, {
where: {
deletedAt: IsNull(),
},
});
if (!product) {
return;
}
return this.translator.translate(product, ctx);
}
// ... other methods
findMany() {}
create() {}
update() {}
}
- The
@Injectable()
decorator is a NestJS decorator which allows the service to be injected into other services or resolvers. - The
constructor()
method is where the dependencies of the service are injected. In this case, theTransactionalConnection
is used to access the database, and theTranslatorService
is used to translate the Product entity into the current language.
Using core services
All the internal Vendure services can be used in your own plugins and scripts. They are listed in the Services API reference and
can be imported from the @vendure/core
package.
To make use of a core service in your own plugin, you need to ensure your plugin is importing the PluginCommonModule
and
then inject the desired service into your own service's constructor:
import { PluginCommonModule, VendurePlugin } from '@vendure/core';
import { MyService } from './services/my.service';
@VendurePlugin({
imports: [PluginCommonModule],
providers: [MyService],
})
export class MyPlugin {}
import { Injectable } from '@nestjs/common';
import { ProductService } from '@vendure/core';
@Injectable()
export class MyService {
constructor(private productService: ProductService) {}
// you can now use the productService methods
}
Accessing the database
One of the main responsibilities of the service layer is to interact with the database. For this, you will be using
the TransactionalConnection
class, which is a wrapper
around the TypeORM DataSource
object. The primary purpose of the TransactionalConnection
is to ensure that database operations can be performed within a transaction (which is essential for ensuring data integrity), even
across multiple services. Furthermore, it exposes some helper methods which make it easier to perform common operations.
Always pass the RequestContext
(ctx
) to the TransactionalConnection
methods. This ensures the operation occurs within
any active transaction.
There are two primary APIs for accessing data provided by TypeORM: the Find API and the QueryBuilder API.
The Find API
This API is the most convenient and type-safe way to query the database. It provides a powerful type-safe way to query including support for eager relations, pagination, sorting, filtering and more.
Here are some examples of using the Find API:
import { Injectable } from '@nestjs/common';
import { ID, RequestContext, TransactionalConnection } from '@vendure/core';
import { IsNull } from 'typeorm';
import { Item } from '../entities/item.entity';
@Injectable()
export class ItemService {
constructor(private connection: TransactionalConnection) {}
findById(ctx: RequestContext, itemId: ID): Promise<Item | null> {
return this.connection.getRepository(ctx, Item).findOne({
where: { id: itemId },
});
}
findByName(ctx: RequestContext, name: string): Promise<Item | null> {
return this.connection.getRepository(ctx, Item).findOne({
where: {
// Multiple where clauses can be specified,
// which are joined with AND
name,
deletedAt: IsNull(),
},
});
}
findWithRelations() {
return this.connection.getRepository(ctx, Item).findOne({
where: { name },
relations: {
// Join the `item.customer` relation
customer: true,
product: {
// Here we are joining a nested relation `item.product.featuredAsset`
featuredAsset: true,
},
},
});
}
findMany(ctx: RequestContext): Promise<Item[]> {
return this.connection.getRepository(ctx, Item).find({
// Pagination
skip: 0,
take: 10,
// Sorting
order: {
name: 'ASC',
},
});
}
}
Further examples can be found in the TypeORM Find Options documentation.
The QueryBuilder API
When the Find API is not sufficient, the QueryBuilder API can be used to construct more complex queries. For instance,
if you want to have a more complex WHERE
clause than what can be achieved with the Find API, or if you want to perform
sub-queries, then the QueryBuilder API is the way to go.
Here are some examples of using the QueryBuilder API:
import { Injectable } from '@nestjs/common';
import { ID, RequestContext, TransactionalConnection } from '@vendure/core';
import { Brackets, IsNull } from 'typeorm';
import { Item } from '../entities/item.entity';
@Injectable()
export class ItemService {
constructor(private connection: TransactionalConnection) {}
findById(ctx: RequestContext, itemId: ID): Promise<Item | null> {
// This is simple enough that you should prefer the Find API,
// but here is how it would be done with the QueryBuilder API:
return this.connection.getRepository(ctx, Item).createQueryBuilder('item')
.where('item.id = :id', { id: itemId })
.getOne();
}
findManyWithSubquery(ctx: RequestContext, name: string) {
// Here's a more complex query that would not be possible using the Find API:
return this.connection.getRepository(ctx, Item).createQueryBuilder('item')
.where('item.name = :name', { name })
.andWhere(
new Brackets(qb1 => {
qb1.where('item.state = :state1', { state1: 'PENDING' })
.orWhere('item.state = :state2', { state2: 'RETRYING' });
}),
)
.orderBy('item.createdAt', 'ASC')
.getMany();
}
}
Further examples can be found in the TypeORM QueryBuilder documentation.
Working with relations
One limitation of TypeORM's typings is that we have no way of knowing at build-time whether a particular relation will be joined at runtime. For instance, the following code will build without issues, but will result in a runtime error:
const product = await this.connection.getRepository(ctx, Product).findOne({
where: { id: productId },
});
if (product) {
console.log(product.featuredAsset.preview);
// ^ Error: Cannot read property 'preview' of undefined
}
This is because the featuredAsset
relation is not joined by default. The simple fix for the above example is to use
the relations
option:
const product = await this.connection.getRepository(ctx, Product).findOne({
where: { id: productId },
relations: { featuredAsset: true },
});
or in the case of the QueryBuilder API, we can use the leftJoinAndSelect()
method:
const product = await this.connection.getRepository(ctx, Product).createQueryBuilder('product')
.leftJoinAndSelect('product.featuredAsset', 'featuredAsset')
.where('product.id = :id', { id: productId })
.getOne();
Using the EntityHydrator
But what about when we do not control the code which fetches the entity from the database? For instance, we might be implementing
a function which gets an entity passed to it by Vendure. In this case, we can use the EntityHydrator
to ensure that a given relation is "hydrated" (i.e. joined) before we use it:
import { EntityHydrator, ShippingCalculator } from '@vendure/core';
let entityHydrator: EntityHydrator;
const myShippingCalculator = new ShippingCalculator({
// ... rest of config omitted for brevity
init(injector) {
entityHydrator = injector.get(EntityHydrator);
},
calculate: (ctx, order, args) => {
// ensure that the customer and customer.groups relations are joined
await entityHydrator.hydrate(ctx, order, { relations: ['customer.groups' ]});
if (order.customer?.groups?.some(g => g.name === 'VIP')) {
// ... do something special for VIP customers
} else {
// ... do something else
}
},
});
Joining relations in built-in service methods
Many of the core services allow an optional relations
argument in their findOne()
and findMany()
and related methods.
This allows you to specify which relations should be joined when the query is executed. For instance, in the ProductService
there is a findOne()
method which allows you to specify which relations should be joined:
const productWithAssets = await this.productService
.findOne(ctx, productId, ['featuredAsset', 'assets']);