docs/versioned_docs/version-7.0/guide/01-first-entity.md
Before you start, ensure you meet the following pre-requisites first:
If not certain, confirm the prerequisites by running:
node -v
npm -v
Let's start with the basic folder structure. As we said we will have 3 modules, each having its own directory:
# create the project folder and `cd` into it
mkdir blog-api && cd blog-api
# create module folders, inside `src` folder
mkdir -p src/modules/{user,article,common}
Now add the dependencies:
npm install @mikro-orm/core \
@mikro-orm/sqlite \
fastify
And some development dependencies:
npm install --save-dev @mikro-orm/cli \
typescript \
tsx \
@types/node \
vitest
You probably heard about ECMAScript Modules (ESM), but this might easily be the first time you try them.
You do not have to use ESM to use MikroORM. MikroORM can work in ESM projects, as well as CommonJS (CJS) projects.
In a nutshell, for ESM project we need to:
"type": "module" to package.jsonimport/export statements instead of require calls.js extension in those imports, even in TypeScript filesYou can read more about the ESM support in Node.js here.
We will use the following TypeScript config, so create the tsconfig.json file and copy it there. If you know what you are doing, you can adjust the configuration to fit your needs.
For ESM support to work, we need to set module and moduleResolution to NodeNext and target ES2024. We also enable strict mode. Lastly, we tell TypeScript to compile into dist folder via outDir and make it include all *.ts files inside src folder.
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2024",
"strict": true,
"outDir": "dist"
},
"include": [
"./src/**/*.ts"
]
}
Next, we will set up the CLI config for MikroORM. This config will be then automatically imported into your app too. We will use the defineConfig helper that provides intellisense even in JavaScript files.
For tests, you can import the config and override some options before evaluating it.
import { defineConfig } from '@mikro-orm/sqlite';
import { UserSchema } from './modules/user/user.entity.js';
export default defineConfig({
// for simplicity, we use the SQLite database, as it's available pretty much everywhere
dbName: 'sqlite.db',
// explicitly list your entities - we'll create the User entity next
entities: [UserSchema],
// enable debug mode to log SQL queries and discovery information
debug: true,
});
We import the entity schema directly and pass it to the entities array. This is more explicit than folder-based discovery and gives you better control over what entities are registered.
The
defineConfighelper infers the driver type automatically, so no need to specify it explicitly.
Save this file into src/mikro-orm.config.ts, so it will get compiled together with the rest of your app.
Alternatively, you can use
mikro-orm.config.jsfile in the root of your project, such a file will get loaded automatically. Consult the documentation for more info.
{
"type": "module",
"dependencies": { ... },
"devDependencies": { ... }
}
Lastly, add some NPM scripts to ease the development. We will build the app via tsc, test it via vitest and run it locally via tsx.
{
"type": "module",
"dependencies": { ... },
"devDependencies": { ... },
"scripts": {
"build": "tsc",
"start": "tsx src/server.ts",
"test": "vitest"
}
}
We refer to a file
src/server.tsin thestartscript, we will create that later, no need to worry about it right now.
Note that the config references the User entity which we haven't created yet. The CLI will fail until we create it, so let's do that next.
:::info
Check out the Defining Entities section which provides many examples of various property types as well as different ways to define your entities.
:::
Time to create your first entity - the User! Create a user.entity.ts file in src/modules/user with the following contents:
import { defineEntity, type InferEntity, p } from '@mikro-orm/core';
export const UserSchema = defineEntity({
name: 'User',
properties: {
id: p.integer().primary(),
fullName: p.string(),
email: p.string(),
password: p.string(),
bio: p.text().default(''),
},
});
export type IUser = InferEntity<typeof UserSchema>;
So what do we have here? We use the defineEntity helper to create an entity schema for the User. The InferEntity type utility extracts the TypeScript type from the schema, giving us a User type we can use throughout our code.
The p export provides type-safe property builders like p.string(), p.integer(), p.text(), etc. These builders use a fluent API where you can chain options like .primary(), .default(), and more.
Properties with .default() or .onCreate() are automatically optional in em.create() calls - for example, bio has a default value of '', so it doesn't need to be provided.
If you need to add custom methods to your entity, you can extend the schema's auto-generated class via setClass():
export const UserSchema = defineEntity({
name: 'User',
properties: {
// ...
},
});
export class User extends UserSchema.class {
// custom methods go here
}
UserSchema.setClass(User);
This avoids redeclaring all properties in the class - they are inferred from the schema automatically. We will use this pattern in Chapter 2 when adding methods to the User entity.
Every entity needs to have a primary key. You define it using the .primary() builder method. For a single numeric primary key, auto-increment is assumed automatically:
id: p.integer().primary(),
In case you want to use bigint column type, use the p.bigint() builder. BigInts are mapped to string by default, as JavaScript number cannot safely represent large integers:
id: p.bigint().primary(),
Another common use case is UUID. You can use onCreate to generate a value when the entity is first persisted:
import { v4 } from 'uuid';
// ...
uuid: p.uuid().primary().onCreate(() => v4()),
To map regular database columns, we use the property builders from defineEntity.properties. Each builder corresponds to a data type:
p.string() - maps to varcharp.text() - maps to text (for longer strings)p.integer() - maps to integerp.boolean() - maps to booleanp.datetime() - maps to datetime/timestampp.json<T>() - maps to json/jsonb with typed contentFor our User.bio we want to use text instead of varchar, and provide a default value:
bio: p.text().default(''),
The .default() method sets both the runtime default and the database column default. Properties with defaults are automatically marked as optional in TypeScript (the Opt type is inferred).
You can chain additional options:
// with explicit column type
bio: p.text().columnType('character varying(1000)'),
// with length constraint
description: p.string().length(1000),
When using .columnType(), be careful about options like length or precision/scale - columnType is always used as-is. This means you need to pass the final value there, including the length, e.g. .columnType('decimal(10,2)').
Now that we have both the config and the entity, test the CLI via npx mikro-orm debug:
Current MikroORM CLI configuration
- dependencies:
- mikro-orm 7.0.0
- node 24.11.1
- typescript 5.9.3
- package.json found
- TypeScript support enabled (tsx)
- configuration found
- database connection successful
- will use `entities` array (contains 1 references)
The last missing step is to initialize the MikroORM to get access to the EntityManager and other handy tools (like the SchemaGenerator).
import { MikroORM } from '@mikro-orm/sqlite';
import config from './mikro-orm.config.js';
const orm = await MikroORM.init(config);
:::info Synchronous initialization
As opposed to the async MikroORM.init method, you can prefer to use synchronous variant with the constructor: new MikroORM().
const orm = new MikroORM(config);
This method has some limitations:
FileCacheAdapter needs to be explicitly set in the config:::
So now you have the access to EntityManager, let's talk about how it works and how you can use it.
There are 2 methods we should first describe to understand how persisting works in MikroORM: em.persist() and em.flush().
em.persist(entity) is used to mark new entities for future persisting. It will make the entity managed by the EntityManager and once flush will be called, it will be written to the database.
We use em.create() to create entity instances.
// create a new user entity instance
const user = em.create(UserSchema, {
email: '[email protected]',
fullName: 'Foo Bar',
password: '123456',
});
// em.create() automatically calls persist(), so we just need to flush
await em.flush();
// alternatively, you can persist manually:
const user2 = em.create(UserSchema, { ... }, { persist: false });
em.persist(user2);
await em.flush();
To understand flush, let's first define what managed entity is: An entity is managed if it's fetched from the database (via em.find()) or registered as new through em.persist() and flushed later (only after the flush it becomes managed).
em.flush() will go through all managed entities, compute appropriate change sets and perform according database queries. As an entity loaded from the database becomes managed
automatically, you do not have to call persist on those, and flush is enough to update them.
const user = await em.findOne(UserSchema, 1);
user.bio = '...';
// no need to persist `user` as it's already managed by the EM
await em.flush();
Let's try to create our first record in the database, add this to the server.ts file:
import { MikroORM } from '@mikro-orm/sqlite';
import config from './mikro-orm.config.js';
import { UserSchema } from './modules/user/user.entity.js';
const orm = await MikroORM.init(config);
// create new user entity instance via em.create()
const user = orm.em.create(UserSchema, {
email: '[email protected]',
fullName: 'Foo Bar',
password: '123456',
});
// em.create() auto-persists, so just flush
await orm.em.flush();
// after the entity is flushed, it becomes managed, and has the PK available
console.log('user id is:', user.id);
Now run the script again via npm start, and you will see an error:
ValidationError: Using global EntityManager instance methods for context specific actions is disallowed.
If you need to work with the global instance's identity map, use `allowGlobalContext` configuration option
or `fork()` instead.
Remember we said the orm.em is a global EntityManager instance? Looks like it is not a good idea to use it, in fact, it is disallowed by default. Before we get to the bottom of this message, let's quickly define two more terms we haven't touched yet - the Identity Map and Unit of Work.
MikroORM is a data-mapper that tries to achieve persistence-ignorance. This means you map JavaScript objects into a relational database that doesn't necessarily know about the database at all. How does it work?
MikroORM uses the Identity Map pattern to track objects. Whenever you fetch an object from the database, MikroORM will keep a reference to this object inside its UnitOfWork. This allows MikroORM room for optimizations. If you call the EntityManager and ask for an entity with a specific ID twice, it will return the same instance:
const jon1 = await em.findOne(Author, 1);
const jon2 = await em.findOne(Author, 1);
// identity map in action
console.log(jon1 === jon2); // true
The Identity Map only knows objects by id, so a query for different criteria has to go to the database, even if it was executed just before. But instead of creating a second Author object MikroORM first gets the primary key from the row and checks if it already has an object inside the UnitOfWork with that primary key.
The identity map has a second, more important use-case. Whenever you call em.flush(), the ORM will iterate over the Identity Map, and for each entity it compares the original state with the values that are currently set on the entity. If changes are detected, the object is queued for an SQL UPDATE operation. Only the fields that changed are part of the update query.
The following code will update your database with the changes made to the Author object, even if you did not call em.persist():
const jon = await em.findOne(Author, 1);
jon.email = '[email protected]';
await em.flush();
The most important implication of having Unit of Work is that it allows handling transactions automatically.
When you call em.flush(), all computed changes are queried inside a database transaction. This means that you can control the boundaries of transactions by calling em.persist() and once all your changes are ready, calling flush() will run them inside a transaction.
You can also control the transaction boundaries manually via
em.transactional(cb).
const user = await em.findOne(UserSchema, 1);
user.email = '[email protected]';
await em.flush();
You can find more information about transactions in Transactions and concurrency page.
Now back to the validation error about global context. With the freshly gained knowledge, we know EntityManager maintains a reference to all the managed entities in the Identity Map. Imagine we would use a single Identity Map throughout our application (so a single global context, global EntityManager). It will be shared across all request handlers, that can run in parallel.
growing memory footprint
As there would be only one shared Identity Map, we can't just clear it after our request ends. There can be another request working with it so clearing the Identity Map from one request could break other requests running in parallel. This will result in a growing memory footprint, as every entity that became managed at some point in time would be kept in the Identity Map.
unstable response of API endpoints
Every entity has toJSON() method, that automatically converts it to serialized form If we have only one shared Identity Map, the following situation may occur:
Let's say there are 2 endpoints
GET /article/:id that returns just the article, without populating anythingGET /article-with-author/:id that returns the article and its author populatedNow when someone requests the same article via both of those endpoints, we could end up with both returning the same output:
GET /article/1 returns Article without populating its property author propertyGET /article-with-author/1 returns Article, this time with author populatedGET /article/1 returns Article, but this time also with author populatedThis happens because the information about entity association being populated is stored in the Identity Map.
So we understand the problem better now, what's the solution? The error suggests it - forking. With the fork() method we get a clean EntityManager instance, that has a fresh Unit of Work with its own context and Identity Map.
// fork first to have a separate context
const em = orm.em.fork();
// create and persist the user in the forked context
const user = em.create(UserSchema, {
email: '[email protected]',
fullName: 'Foo Bar',
password: '123456',
});
await em.flush();
Running npm start again, you get past the global context validation error, but only to find another one:
TableNotFoundException: insert into `user` (`bio`, `email`, `full_name`, `password`) values ('', '[email protected]', 'Foo Bar', '123456') - no such table: user
We forgot to create the database schema. Fortunately, we have all the tools we need at hand. You can use the SchemaGenerator provided by MikroORM to create the schema, as well as to keep it in sync when you change your entities. For the initial testing, let's use the refresh() method, which is handy for testing - it will first drop the schema if it already exists and create it from scratch based on entity definition (metadata).
// recreate the database schema
await orm.schema.refresh();
Finally, npm start should succeed, and if you enabled the debug mode in your config, you will see the SQL queries in the logs, as well as the user.id value at the very end.
[query] create table `user` (`id` integer not null primary key autoincrement, `full_name` text not null, `email` text not null, `password` text not null, `bio` text not null); [took 1 ms]
[query] pragma foreign_keys = on; [took 0 ms]
[query] begin
[query] insert into `user` (`bio`, `email`, `full_name`, `password`) values ('', '[email protected]', 'Foo Bar', '123456') [took 0 ms]
[query] commit
user id is: 1
You can see the insert query being wrapped inside a transaction. That is another effect of the Unit of Work. The em.flush() call will perform all the queries inside a transaction. If something fails, the whole transaction will be rolled back.
We have our first entity stored in the database. To read it from there we can use find() and findOne() methods.
// find user by PK, same as `em.findOne(UserSchema, { id: 1 })`
const userById = await em.findOne(UserSchema, 1);
// find user by email
const userByEmail = await em.findOne(UserSchema, { email: '[email protected]' });
// find all users
const allUsers = await em.find(UserSchema, {});
We mentioned the Identity Map several times already - time to test how it works. We said the entity is managed, and the Unit of Work will track its changes, and compute them when we call flush(). We also said a new entity that is marked with persist() will become managed after flushing.
Put the following code into your server.ts file, right before the orm.close() call:
// user entity is now managed, if we try to find it again, we get the same reference
const myUser = await em.findOne(UserSchema, user.id);
console.log('users are the same?', user === myUser)
// modifying the user and flushing yields update queries
user.bio = '...';
await em.flush();
Run the npm start again and verify the logs:
users are the same? true
[query] begin
[query] update `user` set `bio` = '...' where `id` = 1 [took 0 ms]
[query] commit
Next, let's try to do the same, but with an EntityManager fork:
// now try to create a new fork, does not matter if from `orm.em` or our existing `em` fork, as by default we get a clean one
const em2 = em.fork();
console.log('verify the EM ids are different:', em.id, em2.id);
const myUser2 = await em2.findOneOrFail(UserSchema, user.id);
console.log('users are no longer the same, as they came from different EM:', user === myUser2);
Which logs the following:
verify the EM ids are different: 3 4
[query] select `u0`.* from `user` as `u0` where `u0`.`id` = 1 limit 1 [took 0 ms]
users are no longer the same, as they came from different EM: false
:::info
We just used em.findOneOrFail() instead of em.findOne(), as you may have guessed, its purpose is to always return a value, or throw otherwise.
:::
You can see there is a select query to load the user. This is because you used a new fork, that is clean by default—it has an empty Identity Map, and therefore it needs to load the entity from the database. In the previous example, you already had it present by the time you were calling em.findOne(). You queried the entity by its primary key, and such a query will always first check the identity map and prefer the results from it instead of querying the database.
The behavior described above is often what you want and serves as a first-level cache, but what if you always want to reload that entity, regardless of the existing state? There are several options:
FindOptionsis the last parameter ofem.find/findOnemethods.
disableIdentityMap: true in the FindOptionsem.refresh(entity)The first two have pretty much the same effect, using disableIdentityMap just does the forking for us behind the scenes. Let's talk about the last one - refreshing. With em.refresh(), the EntityManager will ignore the contents of the Identity Map and always fetch the entity from the database.
// change the user
myUser2.bio = 'changed';
// reload user with `em.refresh()`
await em2.refresh(myUser2);
console.log('changes are lost', myUser2);
// let's try again
myUser2!.bio = 'some change, will be saved';
await em2.flush();
Running the npm start script again, you get the following:
[query] select `u0`.* from `user` as `u0` where `u0`.`id` = 1 limit 1 [took 1 ms, 1 result]
changes are lost User {
fullName: 'Foo Bar',
email: '[email protected]',
password: '123456',
bio: '...',
id: 1
}
[query] begin
[query] update `user` set `bio` = 'some change, will be saved' where `id` = 1 [took 0 ms, 1 row affected]
[query] commit
We touched on creating, reading and updating entities, the last piece of the puzzle to the CRUD riddle is the delete operation. To delete entities via EntityManager, we have two possibilities:
em.remove() - this means we first need to have the entity instance. But don't worry, you can get one even without loading it from the database - via em.getReference().DELETE query via em.nativeDelete() - when all you want is a simple delete query, it can be simple as that.Let's test the first approach with removing by entity instance:
// finally, remove the entity
await em2.remove(myUser3!).flush();
So what does the em.getReference() method mentioned above do and what is an entity reference in the first place?
MikroORM represents every entity as an object, even those that are not fully loaded. Those are called entity references - they are in fact regular entity class instances, but only with their primary key available. This makes it possible to create them without querying the database. References are stored in the identity map just like any other entity.
An alternative to the previous code snippet could be as well this:
const userRef = em.getReference(UserSchema, 1);
await em.remove(userRef).flush();
This concept is especially important for relationships and can be combined with the so-called Reference wrapper for added type safety, but we will get to that later.
WrappedEntityWe just said that entity reference is a regular entity, but only with a primary key. How does it work? During entity discovery (which happens when you call MikroORM.init()), the ORM will patch the entity prototype and generate a lazy getter for the WrappedEntity - a class holding various metadata and state information about the entity. Each entity instance will have one, available under a hidden __helper property - to access its API in a type-safe way, use the wrap() helper:
import { wrap } from '@mikro-orm/core';
const userRef = em.getReference(UserSchema, 1);
console.log('userRef is initialized:', wrap(userRef).isInitialized());
await wrap(userRef).init();
console.log('userRef is initialized:', wrap(userRef).isInitialized());
The WrappedEntity instance also holds the state of the entity at the time it was loaded or flushed - this state is then used by the Unit of Work during flush to compute the differences. Another use case is serialization, we can use the toObject(), toPOJO() and toJSON() methods to convert the entity instance to a plain JavaScript object.
In this guide, we use defineEntity with InferEntity for type inference, and setClass when custom methods are needed. However, MikroORM supports multiple ways to define entities:
You can define entities using class decorators like @Entity(), @Property(), @ManyToOne(), etc. MikroORM v7 supports both legacy (experimental) decorators and the new ES spec decorators:
// Legacy decorators (requires experimentalDecorators)
import { Entity, PrimaryKey, Property } from '@mikro-orm/decorators/legacy';
// ES spec decorators (no config needed)
import { Entity, PrimaryKey, Property } from '@mikro-orm/decorators/es';
@Entity()
export class User {
@PrimaryKey()
id!: number;
@Property()
fullName!: string;
@Property()
email!: string;
}
When using decorators, you'll need to choose a metadata provider to handle type inference. See the Using Decorators guide for a comprehensive overview of:
TsMorphMetadataProvider for DRY entity definitionsReflectMetadataProvider for lightweight setupInstead of explicitly listing entities, you can use glob patterns to discover entities automatically:
export default defineConfig({
entities: ['dist/**/*.entity.js'],
entitiesTs: ['src/**/*.entity.ts'],
});
This is particularly useful for large projects with many entities. See Folder-based Discovery for details.
:::tip ESM and TypeScript file resolution
When using folder-based discovery in an ESM project with test runners like Vitest, you may encounter an error like TypeError: Unknown file extension ".ts" (ERR_UNKNOWN_FILE_EXTENSION). This happens because the dynamic import of your entities fails to resolve TypeScript files - MikroORM performs these imports internally, and tools like Vitest cannot automatically transform them.
To work around this, you can override the dynamicImportProvider option in your ORM config. This allows you to use an import call defined inside the context of your ESM application:
export default defineConfig({
// ...
// for vitest to get around `TypeError: Unknown file extension ".ts"` (ERR_UNKNOWN_FILE_EXTENSION)
dynamicImportProvider: id => import(id),
});
This tells MikroORM to use your application's import context instead of its own, allowing proper TypeScript file resolution.
:::
Check the Defining Entities documentation for more examples of all entity definition approaches.
If you already have a database with tables and want to generate entity files from it, you can use the Entity Generator. Install the @mikro-orm/entity-generator package and register the EntityGenerator extension:
import { EntityGenerator } from '@mikro-orm/entity-generator';
export default defineConfig({
// ...
extensions: [EntityGenerator],
});
Then run it via CLI:
npx mikro-orm generate-entities --save --path=./src/modules
This will introspect your database schema and generate entity files for each table. You can then adjust the generated files and continue with the code-first approach from there.
Alternatively, if you prefer to keep the database schema as the source of truth and regenerate entity files on every schema change, check out the Schema First Guide.
Currently, our app consists of a single User entity and a server.ts file where we tested how to work with it using EntityManager. You can find working StackBlitz for the current state here:
We use in-memory database, SQLite feature available via special database name
:memory:.
This is our server.ts file so far: