Hi Meteor community,
I wanted to share a package I have been using for a while in production and local development:
meteorjs-decorators brings a NestJS-style structure to Meteor apps: controllers, services, modules, dependency injection, DTO validation, response DTO mapping, publications, entities, and indexes.
The goal is to make bigger Meteor apps easier to organize, test, and scale.
I have been using this approach in a real app and it has helped me keep server code much more maintainable than scattered Meteor.methods, Meteor.publish, check, and ad-hoc service imports.
There is also a public example repo here:
That project shows this approach in a real Meteor app with the Rspack bundler.
Install and setup
Install the package with the validation dependencies:
meteor npm install meteorjs-decorators reflect-metadata class-validator class-transformer
Or with Yarn:
yarn add meteorjs-decorators reflect-metadata class-validator class-transformer
Enable decorator metadata in tsconfig.json:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
Then import reflect-metadata once on server startup:
import 'reflect-metadata';
import '/imports/api/app.module';
The most important decorators are:
@Controller()and@Method(name)for Meteor methods.@Publication(name)for Meteor publications.@Injectable(),@Inject(), and@Module()for dependency injection and feature modules.@Auth()and@CheckPermissions()for guards.@Validate()for request DTO validation.@Dto()and@ReactiveDto()for response shaping.@Entity()and@Index()for collection and index metadata.
Why I built this
Meteor is still a very productive full-stack framework, but when an app grows, it is easy for server code to become hard to follow:
- Methods are registered in many different files.
- Validation logic gets repeated.
- Business logic leaks into method handlers.
- Publications return raw collection data.
- Testing methods often requires knowing too much about implementation details.
With meteorjs-decorators, I can organize a module like this:
imports/api/Users/
users.module.ts
users.controller.ts
users.service.ts
user.repository.ts
user.entity.ts
users.publication.ts
dtos/
save-user-request.dto.ts
user-request.dto.ts
user-response.dto.ts
user-publication-response.dto.ts
That gives me a clear N-layer architecture:
Controller -> Service -> Repository -> Entity
The controller owns the Meteor API surface, the service owns business rules, the repository owns database queries, and the entity owns collection metadata and indexes.
Controller example
This is close to the users module I use in production:
import { Auth, CheckPermissions, Controller, Dto, Method, Validate } from 'meteorjs-decorators';
import Permissions from '../Permissions/helpers/permissions.helpers';
import { SaveUserRequestDto } from './dtos/save-user-request.dto';
import { UserDeleteRequestDto } from './dtos/user-delete-request.dto';
import { UserResponseDto } from './dtos/user-response.dto';
import { UserService } from './users.service';
import { DeleteResponse } from '/imports/common/dtos/delete-response.dto';
@Controller()
export class UsersController extends BaseController {
constructor(private readonly userService: UserService) {}
@Method('user.save')
@CheckPermissions(Permissions.USERS.CREATE.VALUE, Permissions.USERS.UPDATE.VALUE)
@Validate(SaveUserRequestDto)
@Dto(UserResponseDto)
async saveUser(request: SaveUserRequestDto) {
const { user } = request;
await this.userService.validateEmail(user.email, user.id);
await this.userService.validateUsername(user.username, user.id);
await this.userService.validateProfile(user.profile.profile);
return this.userService.saveUser(request);
}
@Method('user.delete')
@CheckPermissions(Permissions.USERS.DELETE.VALUE)
@Validate(UserDeleteRequestDto)
@Dto(DeleteResponse)
async deleteUser(request: UserDeleteRequestDto) {
await this.userService.deleteUser(request.userId);
}
@Method('user.updatePersonalData')
@Auth()
@Validate(SaveUserRequestDto)
@Dto(UserResponseDto)
async updatePersonalData(request: SaveUserRequestDto) {
const { user } = request;
await this.userService.validateEmail(user.email, this.__context.userId);
await this.userService.validateUsername(user.username, this.__context.userId);
request.user.id = this.__context.userId;
return this.userService.saveUser(request);
}
}
The method is still a Meteor method. From the client, you can keep calling it normally:
Meteor.call('user.save', { user }, callback);
But on the server, the method now has a focused place in the app architecture.
Service example
The service contains business logic and can depend on other services:
import { Accounts } from 'meteor/accounts-base';
import { Meteor } from 'meteor/meteor';
import { Inject, Injectable, forwardRef } from 'meteorjs-decorators';
import { ProfilesService } from '../Profiles/profiles.service';
import { SaveUserRequestDto } from './dtos/save-user-request.dto';
import { UserRepository } from './user.repository';
@Injectable()
export class UserService {
private userRepository = new UserRepository();
constructor(
@Inject(forwardRef(() => ProfilesService))
private profilesService: ProfilesService
) {}
async validateEmail(newEmail: string, userId: string) {
const existingUser = await Accounts.findUserByEmail(newEmail);
if (!userId && existingUser) {
throw new Meteor.Error('403', 'The new email is already in use');
}
}
async saveUser(request: SaveUserRequestDto) {
const { user } = request;
let userId = user.id;
if (userId) {
const userToUpdate = await this.userRepository.findOneOrFail(user.id);
userToUpdate.username = user.username;
userToUpdate.profile = user.profile;
await this.userRepository.upsert(userToUpdate._id, {
$set: { profile: userToUpdate.profile },
});
} else {
userId = await Accounts.createUserAsync({
username: user.username,
email: user.email,
profile: user.profile,
});
}
return this.userRepository.findOneOrFail(userId);
}
}
This keeps the controller small. The controller validates and authorizes the call, then delegates real work to the service.
Repository example
Repositories keep Mongo selectors, pagination, sorting, and collection operations in one place.
For example, the users repository can expose regular user queries like this:
import { Mongo } from 'meteor/mongo';
import { StaticProfiles } from '../Profiles/constants/static-profiles.constant';
import { User } from './user.entity';
import { BaseRepository } from '/imports/common/repositories/base.repository';
export class UserRepository extends BaseRepository<User> {
constructor() {
super(User.collection);
}
async deleteById(id: string): Promise<number> {
return this.softDelete({ _id: id });
}
async findUserById(userId: string): Promise<User> {
return this.findOneOrFail(userId);
}
async findUsersPage({
page,
limit,
excludeUserId,
}: {
page: number;
limit: number;
excludeUserId: string;
}): Promise<User[]> {
const externalProfileNames = Object.keys(StaticProfiles)
.filter((profileName) => StaticProfiles[profileName].external)
.map((profileName) => StaticProfiles[profileName].name);
const selector: Mongo.Selector<User> = {
_id: { $ne: excludeUserId },
'profile.profile': { $nin: externalProfileNames },
};
return this.find(selector, {
fields: {
username: 1,
emails: 1,
createdAt: 1,
profile: 1,
status: 1,
},
limit,
skip: (page - 1) * limit,
sort: { 'profile.name': 1 },
});
}
async findUsersTotal(excludeUserId: string): Promise<number> {
const externalProfileNames = Object.keys(StaticProfiles)
.filter((profileName) => StaticProfiles[profileName].external)
.map((profileName) => StaticProfiles[profileName].name);
return this.findAll({
_id: { $ne: excludeUserId },
'profile.profile': { $nin: externalProfileNames },
}, {
fields: { _id: 1 },
}).countAsync();
}
}
In my app, repositories extend a small BaseRepository that wraps the common Mongo operations. This keeps repeated concerns like soft-delete filtering and updatedAt handling out of each feature repository:
import { StatusCodes } from 'http-status-codes';
import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
export class BaseRepository<T> {
protected collection: Mongo.Collection<T>;
constructor(collection: Mongo.Collection<T>) {
this.collection = collection;
}
findAll(selector: Mongo.Selector<T> = {}, options: Mongo.Options<T> = {}) {
return this.collection.find({
...selector,
$or: [{ deletedAt: { $exists: false } }, { deletedAt: null }],
}, options);
}
find(selector: Mongo.Selector<T> = {}, options: Mongo.Options<T> = {}): Promise<T[]> {
return this.collection.find({
...selector,
$or: [{ deletedAt: { $exists: false } }, { deletedAt: null }],
}, options).fetchAsync();
}
insert(document: Mongo.OptionalId<T>): Promise<string> {
return this.collection.insertAsync({
...document,
updatedAt: new Date(),
deletedAt: null,
});
}
async update(
selector: Mongo.Selector<T> | Mongo.ObjectID | string,
modifier: Mongo.Modifier<T>
): Promise<number> {
modifier.$set = {
...modifier.$set,
updatedAt: new Date(),
};
return this.collection.updateAsync(selector, modifier);
}
async findOneOrFail(
selector: Mongo.Selector<T> | Mongo.ObjectID | string,
errorMessage = 'Document not found'
): Promise<T> {
const document = await this.collection.findOneAsync(selector);
if (!document) {
throw new Meteor.Error(StatusCodes.UNPROCESSABLE_ENTITY, errorMessage);
}
return document;
}
async softDelete(selector: Mongo.Selector<T>): Promise<number> {
return this.collection.updateAsync(selector, {
$set: { deletedAt: new Date() },
});
}
}
Entity and indexes
Entities can define the collection and indexes:
import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
import { Entity, Index } from 'meteorjs-decorators';
import { BaseEntity } from '/imports/common/entities/base.entity';
@Entity('users', Meteor.users)
export class User extends BaseEntity implements Meteor.User {
static collection: Mongo.Collection<User>;
_id: string;
@Index({ 'profile.name': 'text' })
@Index({ 'profile.profile': 1 }, { name: 'profile.profile' })
profile: {
profile: string;
name: string;
path?: string;
};
@Index({ 'status.online': 1 }, { name: 'status.online' })
status: {
online: boolean;
idle?: boolean;
};
username?: string;
emails?: Meteor.UserEmail[];
createdAt?: Date;
updatedAt?: Date;
deletedAt?: Date | null;
}
Modules
Modules group controllers, publications, services, and dependencies:
import { ProfilesModule } from '../Profiles/profiles.module';
import { UsersController } from './users.controller';
import { UsersPublication } from './users.publication';
import { UserService } from './users.service';
import { forwardRef, Module } from 'meteorjs-decorators';
@Module({
imports: [
forwardRef(() => ProfilesModule),
],
controllers: [
UsersController,
UsersPublication,
],
providers: [
UserService,
],
})
export class UsersModule {}
Then the app module imports feature modules:
import { UsersModule } from './Users/users.module';
import { ProfilesModule } from './Profiles/profiles.module';
import { Module } from 'meteorjs-decorators';
@Module({
imports: [
UsersModule,
ProfilesModule,
],
})
export class AppModule {}
Request DTOs instead of check or ValidatedMethod schemas
One of my favorite parts is replacing check, simpl-schema, and ValidatedMethod style validation with DTO classes and class-validator.
Example request DTO:
import { Type } from 'class-transformer';
import { IsEmail, IsOptional, IsString, ValidateNested } from 'class-validator';
import { RequestDto } from 'meteorjs-decorators';
class ProfileDto {
@IsString()
profile: string;
@IsString()
name: string;
@IsString()
@IsOptional()
path?: string;
}
export class UserRequestDto extends RequestDto {
@IsString()
@IsOptional()
id?: string;
@IsString()
username: string;
@IsEmail()
email: string;
@ValidateNested()
@Type(() => ProfileDto)
profile: ProfileDto;
}
And a wrapper DTO:
import { Type } from 'class-transformer';
import { IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator';
import { RequestDto } from 'meteorjs-decorators';
import { UserRequestDto } from './user-request.dto';
export class SaveUserRequestDto extends RequestDto {
@IsNotEmpty()
@ValidateNested()
@Type(() => UserRequestDto)
user: UserRequestDto;
@IsString()
@IsOptional()
photoFileUser?: string;
}
Then the controller only needs:
@Validate(SaveUserRequestDto)
async saveUser(request: SaveUserRequestDto) {
return this.userService.saveUser(request);
}
This makes validation declarative and colocated with TypeScript types.
Response DTOs
Response DTOs let you shape the method response before it goes back to the client.
For example, the raw Meteor user document has _id, emails, services, and other fields. The response DTO can return only what the endpoint should expose:
import { ResponseDto } from 'meteorjs-decorators';
import { User } from '../user.entity';
export class UserResponseDto extends ResponseDto {
id: string;
username: string;
email: string;
createdAt: Date;
profile: User['profile'];
status: User['status'];
build(user: User): UserResponseDto {
this.id = user._id;
this.username = user.username;
this.email = user.emails?.[0]?.address || '';
this.createdAt = user.createdAt;
this.profile = user.profile;
this.status = {
online: user.status?.online || false,
};
return this.send();
}
}
The controller uses it like this:
@Dto(UserResponseDto)
async saveUser(request: SaveUserRequestDto) {
return this.userService.saveUser(request);
}
New publication style
The package also supports a decorator-based publication style:
import { Mongo } from 'meteor/mongo';
import { Auth, Publication, ReactiveDto, Validate } from 'meteorjs-decorators';
import { BasePublication } from '/imports/common/publications/base.publication';
import { UserPublicationResponseDto } from './dtos/user-publication-response.dto';
import { UsersRequestDto } from './dtos/users-request.dto';
import { User } from './user.entity';
import { UserRepository } from './user.repository';
@Publication('users')
export class UsersPublication extends BasePublication {
constructor(
private readonly userRepository: UserRepository = new UserRepository()
) {
super();
}
@Auth()
@Validate(UsersRequestDto)
@ReactiveDto(UserPublicationResponseDto)
init(request: UsersRequestDto = new UsersRequestDto()) {
const selector: Mongo.Selector<User> = {
_id: { $ne: this.__context.userId || undefined },
};
if (request.search?.trim()) {
selector.$text = { $search: request.search.trim() };
}
return this.userRepository.findAll(selector, {
fields: {
username: 1,
emails: 1,
createdAt: 1,
profile: 1,
status: 1,
},
limit: request.limit,
skip: (request.page - 1) * request.limit,
sort: { 'profile.name': 1 },
});
}
}
This is still a Meteor publication, but now it can use @Auth(), request validation, and @ReactiveDto() to map reactive documents before they reach the client.
Integration tests
The endpoint style is also easy to test because the method names are still registered in Meteor.server.method_handlers.
Example:
import chai from 'chai';
import { Accounts } from 'meteor/accounts-base';
import { Factory } from 'meteor/dburles:factory';
import { resetDatabase } from 'meteor/jessedev:cleaner';
import { Meteor } from 'meteor/meteor';
import sinon from 'sinon';
import '/imports/api/app.module';
import '/imports/api/Users/users.controller';
describe('UsersCtrl', function () {
let adminId: string;
let saveUserMethod: any;
before(async function () {
resetDatabase({
excludedCollections: ['roles', 'role-assignment', 'profiles'],
});
adminId = await Accounts.createUserAsync(Factory.tree('user'));
saveUserMethod = Meteor.server.method_handlers['user.save'];
sinon.stub(Accounts, 'sendEnrollmentEmail').returns();
});
after(function () {
sinon.restore();
});
it('creates a new user', async function () {
const newUser = Factory.tree('simpleUser');
const response = await saveUserMethod.apply(
{ userId: adminId },
[{ user: newUser }]
);
chai.assert.equal(response.email, newUser.email);
});
});
In my app, this pattern is used in tests/server/Users/UsersCtrl.test.ts to test create, update, delete, duplicate email, duplicate username, auth context, and error cases.
Backfills and seeders
I also use this architecture together with explicit backfills and seeders.
For example, backfills can be registered by name:
const registeredBackfills = {
[RefreshPermissionsBackfill.backfillName]: RefreshPermissionsBackfill,
[RefreshStaticProfilesBackfill.backfillName]: RefreshStaticProfilesBackfill,
};
And then run through this command:
yarn backfill RefreshPermissionsBackfill
Seeders use the same idea:
export class UsersSeeder {
static seederName = 'users';
async run(): Promise<void> {
const count = Number(process.env.SEED_COUNT ?? 120);
for (let index = 0; index < count; index += 1) {
// create test/demo users
}
}
}
And can be run like:
SEED_COUNT=120 yarn seed users
This has been useful for keeping data migrations, recurring maintenance tasks, and local/demo data setup out of the request/response code path.
Related package: meteor-user-presence
I also published another package that may help Meteor 3 apps:
https://www.npmjs.com/package/meteor-user-presence
It replaces the old socialize-user-presence Meteor package and is compatible with Meteor 3 and the Rspack bundler.
I am using it together with this architecture to track online/idle user status in a Meteor 3 app.
Final thought
I know Meteor has its own conventions, and this approach may not be for every app. But I wanted to share it because I think it shows that Meteor can still be used to build scalable, modern, well-structured applications.
For teams that like NestJS-style architecture, decorators, dependency injection, DTO validation, modules, and layered backend code, this package can make Meteor feel much more familiar while still keeping the parts that make Meteor productive.
Package:
Real example app:
User presence package: