#19 | Models, Schema Classes, & Relationships - AdonisJS 7

Adocasts| 00:23:28|Apr 24, 2026
Chapters21
Introduces schema classes in AdonisJS v7 and explains they are generated from migrations to model database tables after migrations.

AdonisJS 7 introduces schema classes and ORM models that auto-map to database tables, with rich relationship decorators and pivot handling for complex joins.

Summary

AdonisJS 7 brings a migration-first approach with auto-generated schema.ts classes that mirror database tables. These schemas aren’t mere types; they actively map column properties through the column decorator, including nullable handling and serialization control. Lucid’s base model powers all data querying, with schemas serving as a middle layer between models and the database. Models represent single rows and are the primary work surface, while relationships—hasMany, belongsTo, and many-to-many—enable intuitive cross-table queries. The video walks through practical examples: auto-updating created_at and updated_at via datetime chaining, overriding default column mappings in models, and using import type to clean up imports. It also covers pivot tables for many-to-many relations and why some sides of a relation may not need explicit modeling. Finally, AdonisJS’s hook system (before/after create, update, delete, etc.) enables lifecycle logic, such as defaulting values before persisting data. The presenter demonstrates creating models with ACE, explains when to modify schemas vs. models, and clarifies naming conventions and foreign keys in relationship mappings.

Key Takeaways

  • Schema.ts contains exported classes for every database table and uses the column decorator to map each field, including nullable columns.
  • The primary key and serializable fields can be controlled via the column decorator (e.g., serialize: null to exclude), and created_at/updated_at can auto-sync using the datetime chain.
  • Models extend a base model and automatically inherit key fields like id, name, and timestamps; changes to schemas are picked up by models automatically when autogeneration runs.
  • Relationships are defined with decorators (hasMany, belongsTo, manyToMany), with defaults that can be overridden by options such as localKey and foreignKey.
  • Pivot tables (many-to-many) are handled via the pivot options in the manyToMany decorator, including automatic timestamping of the pivot records if enabled.
  • Getters and non-mapped properties can be added to models using the @get decorator and standard properties, and can be included in serialization with @computed.
  • Hooks (beforeSave, afterCreate, etc.) enable lifecycle logic before or after CRUD operations, allowing automatic data mutations before persistence.

Who Is This For?

Essential viewing for AdonisJS 7 developers who want to understand how to model schemas and relationships, leverage ACE to scaffold models, and manage complex joins with pivot tables.

Notable Quotes

""Schema classes are new within version 7 of Adon.js and their classes generated from a migration first approach to represent the tables in our database post migration.""
Intro to schema classes and their migration-first mapping.
""Schemas serve as a middle layer between the model and the database itself as an autogenerated representation of those columns that are actually inside of our table so that we don't have to manually define them inside of the model each and every time that we make a change.""
Role of schemas vs. models.
""The primary keys of our tables are also noted via the column decorator.""
Column decorator marks primary keys in schemas.
""Hooks will then provide the model. And since we're doing this before save, it's going to be the model before it's persisted into the database.""
Explanation of lifecycle hooks like beforeSave.
""Pivot timestamps option kind of comes into play to pick up the slack there.""
Pivot table timestamp management for many-to-many relations.

Questions This Video Answers

  • How do AdonisJS 7 schema classes map to database tables?
  • What is the difference between AdonisJS 7 schemas and models?
  • How do you define a many-to-many relationship in AdonisJS Lucid V7?
  • Can I override default foreign keys and column names in AdonisJS models?
  • What are Lucid hooks and how do I use them in AdonisJS 7?
AdonisJS 7Lucid ORMSchema classesModelsRelationshipsHasManyBelongsToManyToManyPivot tablesACE CLI keywords like make:model
Full Transcript
Schema classes are new within version 7 of Adon.js and their classes generated from a migration first approach to represent the tables in our database post migration. So after our migrations have run or done what they've needed to do. Although they are autogenerated, they don't merely represent types. So we aren't going to find them within our Donna.js directory. Instead, we're going to find them within our database schema.ts file. This schema.ts ts file will contain exported schema classes for all the tables within our database. Each class represents a table's columns as well as those columns types. If we go ahead and scroll down within this list, we should eventually find our user schema. This schema represents our users table inside of our database. Each column inside of this table is represented by a property that's decorated via a column decorator. The column decorator instructs Lucid that it's going to be able to actually find this column inside of our database. Any properties without the column decorator are what we call notmapped. Meaning that they aren't mapped to our database directly. And although our database naming convention is to use snake case, adonjs by default is going to convert that naming convention to camel case within our code because the two conventions differ between databases and code in itself. So Lucid's already expecting the difference between those conventions and will handle that transition automatically for us. If any columns within our database are nullable, we will also note that via the type here as well. So this full name could be a string, but it could also be a null value. The primary keys within each of our tables are also noted via the column decorator. The primary keys of our tables are also noted via the column decorator. If our schema eventually becomes serialized as well, we can also omit specific properties here like our password from being serialized with the rest of the data via the column decorator as well by setting serialize as to null. You can also set this to a specific string to change the serialized property name. Finally, we can also mutate and translate datetime values inside of our database to luxen datetime values by chaining off of the column decorator the datetime method. Whenever lucid finds these columns, it will take the ISO string and convert it into a luxen datetime for us, which is also noted again by the type there as well. Within this datetime method, we can also set an autocreate so that whenever a user record is created inside of our database, it will automatically set the created at to the current date and time. And there's also an auto update so that anytime we update a record with Lucid OM, it will automatically set those properties to the current date time as well, keeping highly popular columns like created at and updated at in sync without us having to manually do that inside of our code. Now, you might have noticed that our schema classes are extending a base model class coming from Lucid. This base model is the powerhouse of the OM. Taking this mapping and actually making it usable so that we can query and mutate the data within the tables that these schemas then represent. And as we'll see in a bit, we're actually going to use these schemas to represent models, which is the layer with which we're going to actually work with our database. Now, schemas and models are relatively similar in their purpose. They're both specialized classes that represent a single table within our database, and then the properties within those classes represent columns inside of the table. However, schemas serve as a middle layer between the model and the database itself as an autogenerated representation of those columns that are actually inside of our table so that we don't have to manually define them inside of the model each and every time that we make a change. So models are going to be the layer that we actually work with in code and an instance of a model actually represents a single row of data within a table. These model representations act as a translation layer allowing us to describe the queries and operations in Typescript and then the model will convert that to SQL, execute it, and then translate the results back to TypeScript for us to actually be able to use in the shape that's described by our model and the underlying schema. Similar to how the starter kit started us with our users table migration, it has also started us with inside of our app models directory a user model. Before we take a look at this user model though, let's first inspect a simplified version via our ROS table. So we can jump back into our terminal here and stop our server. Clear that out and let's use the ACE CLI to make a model representation for our RO table. Since instances of model represent a single row of data, the models naming convention is in singular form. So we can hit enter there. Now we'll add a new file into our models directory. And you'll see it looks like this. So we have a RO class that extends our RO schema and then that RO schema ultimately extends the base model which is the powerhouse that kind of provides all of the functionality that we're going to work with. So by extending the RO schema we're automatically already going to be defining the ID name created at and updated at columns directly on the model itself. So we don't need to redeclare those here. However, since our schemas are autogenerated, we're not going to want to directly mutate those schema classes. If we need to extend or change anything with our role model, that happens right here in the role model itself. You're not going to want to change anything directly on the schema because then that's just going to be overwritten the next time that autogeneration runs. For example, within our roles table, let's say that the name column was actually called ROC_ame. If we were to go ahead and save that change just to humor this scenario and run node ace migration refresh to roll back and rerun our migrations, we're going to note that our schema.ts file now defines the column as role name instead of just name. However, let's say inside of our role model, we wanted to refer to that as just name instead of ro name. Now we can easily override what the schema has declared by doing at column and importing that from adonjs lucid om and declaring this as just name as a type of string and then we can within the column declare that the column name that should be used to look this value up inside of the database is now a rod name. So any additives or additional translations that we need to add happen inside of the model, not the schema. All right, we actually don't need that. So I'm just going to go ahead and undo all of that. go back to just a simple declared class there. And then we can jump back into our ROS table and I'm going to change this back to just being name. And we can rerun node ace migration refresh there to pick up that change once more. Jumping back into our schema, we can verify that that is picked up just like so. And it also kind of gives you a sense of how the schema sitting in between our model and the database as an autogenerated layer plays into this whole picture. All that we did was run our migrations, but the schema here automatically updated with that change. And since our model is simple, doesn't overwrite it in any way, it's automatically picked up at the model level as well. Now, let's go ahead and take real quick a look at the make model command. So, node ace make model hyphen help. We've already created our migrations. We're going to be creating everything else one by one as we work through them. But I do want to note that with this make model command, you can also create migrations, controllers, and factories for the model that you're creating all in one go just by adding in these options as needed. So if you wanted to make a model as well as a migration controller and factory for that model, you can do all of that by doing something like node ace make model and we'll reuse ro here as an example and then do hyphen m cf to create a that model as well. All right, I'm going to hittrl c to escape that and not run it and we can clear this out and hide our terminal away momentarily. Another mapping that goes inside of our model and not the schema are relationships. Relationship mappings allow us to describe that a user can have one role and a role can have many users for example. This ultimately will allow us to then query a user record and load with it its role record as well so that it can be displayed as user.name or conversely a role with an array of users that have that role. Within our role model we can easily do this using a set of relationshipbased decorators that Lucid provides. So inside of our role we can declare a users property of type has many and import that from adonjs lucid types relationships and then we provide this a generic that is the type of the related model in this case that's going to be our user model. Now that just describes the property we also need to decorate this so that lucid actually is able to work with it and for that there is a has many decorator that comes from adonjs lucid om. This decorator needs to be provided a callback function that returns back the model that the relationship works with as well. So again that is our user and this is what our relationships look like. Now the red squiggly here is just because the autoimp import did a straight import for has many but what this wants is just the type. So if I hit command dot we can go down to use import type instead. And all that that's going to do is add a type to the import statement, simplifying the code that's actually generated out at buildtime for this model so that it excludes the has many and that's just used as information at the TypeScript layer. Like everything else, Adonjs has conventions that it follows for defaults on our relationships namings. And all of these defaults can be overwritten via an option set that goes in as the second argument to our has many or other relationshipbased decorators. for the has many relationship type. Here we can set the local key which is the local primary key defaulting to the primary key of the parent model ID in our specific case. Here we can also set the foreign key which is the related primary key defaulting to the camelc case concatenation of the parent model name along with its primary key. In our case that's going to be ro ID as defined on our user model again via its schema. So if we go take a look at the user schema right here is where we have that foreign key defined as a column. So that's the name that binds the relationship to our roles users for the has many relationship type. For both of these we're adhering to these defaults. So we don't need to provide either of those and we can keep it nice and simple just like this. Now in total there's five different types of relationships that JS lucid supports. There's the has one in which a user has one profile. Has many in which a role has many users. Belongs to in which a profile belongs to a user. Many to many in which a challenge has many users and a user can have many challenges. That one also utilizes a pivot table that is not represented in model form. Then there's finally a has many through relationship type that serves as more of a utility that allows us to go through a separate has many relationship. For example, we can say a role can have created many challenges through its user. That one's a little bit more complicated in nature. Uh all of these relationship types though except for those prefixed with pivot like our pivot foreign key should use our model's name for the column. For example, a camelcased ro ID instead of the table's name. For example, a snake cased ro ID. The pivot options are an exception because as we'll see in a bit, the pivot tables are represented by a many to many relationship instead of an actual model. So if we go ahead and jump over to our user model now, you'll first notice that there's a little bit more going on with our user model. That's okay. Let's just focus on the task at hand for the moment being. And that task is to define the inverse side of our role relationship. So I'm going to go to the end of our bracket there and insert a couple of new lines. Now the inverse side of a has many or has one relationship is defined by a belongs to relationship type. So although you could say our user only has one role, the more applicable description here since our role has many users is to say that the user belongs to a role. So we'll import belongs to from adonjs lucid o and again this needs the model that the relationship points to. In this case it's going to be our role model. And then we need to declare the property. So that is ro of type belongs to type of a role model. So these all follow a relatively similar shape in nature and again there I'm just updating the belongs to to a type relationship import. There is also a straightforward rule that we can follow to discern whether or not we should use a belongs to or has one. Essentially, if the model that we're describing the relationship on, our user in this case, has the foreign key property inside of its table, our role ID for this relationship, then we're always going to describe this side of the relationship as a belongs to. It's the inverse side then that decides whether or not it's a many relationship or a one-based relationship. Can a role have one user or can a role have many users? In our case, a role can have many users because we want to have many admins, many moderators, or many users in a sense, and we don't want to have to have one role for each individual user that our application has. Okay, while we're here on our user model, let's go ahead and talk about what's going on here with this initials. What we have here is a getter described by the get annotation before the method itself. And this is actually going to behave as though it were just a traditional property. Although the actual method that's described here will be run whenever we reference that property. These getters inside of our models are not mapped properties, meaning that they don't represent a specific column inside of our database. And we can add as many of these to our models as we need to aid our development. One important note about getters though is that these cannot be asynchronous. This is a limitation of the language, not the framework. If you need asynchronous actions, that's where you would want to do an actual method that can be called off of the model itself rather than a getter. Similar to getters, we can define additional properties or really anything that we want. Similar to these getters, we can define any properties or methods or really anything that we want directly on our models so long as the at column decorator and relationship decorators are omitted. They're going to be treated as notmapped properties that don't exist inside of our database. And so, Lucid just won't do anything with them. They're there for our use. They are however omitted whenever the model is serialized by default. So when our models are converted to JSON for example, these properties will be omitted. If we want them to be included, that's where we could decorate them with the computed decorator. So if our user were to get serialized here, the initials getter would be omitted unless we decorate it with computed from adon.js lucid om just like so. Now it will get included along with its value in our serializations. Now of course methods can't be serialized. So this won't work for things like those. But for getters and traditional properties, this will indeed work just fine. Okay, great. While we're here in this lesson, let's go ahead and set up and create the remainder of our models. So we're just going to need two here. We'll need note ace make model for our profile. And then we will also need one for our challenge table just like so. Now, you'll note that we do have five tables in our application compared to just four models. And again, that's because the challenge user is a pivot table. And as we'll see in a moment, we're going to be able to just utilize a relationship for that without actually needing a model. But first, let's go ahead and jump into our profile model and fill this one out because this one's a little bit more simple and very similar to our role relationship. So a profile belongs to a user because again if we take a look at our schema here and we find our profile schema this has the foreign key for the relationship our user ID on it. So this is the side that's going to be our belongs to and then we need to point that to our user model because it belongs to the user and then we can declare its property as belongs to type of user just like so and again I'm going to hit command dot and use import type and then on the user side of things instead of being a belongs to this is now going to be a has one again because we only want a user to have one profile we aren't going to have any two users sharing the same profile. Each user record gets its own profile here. So we'll point this to our profile model and declare profile as has one type of profile. Then for our challenge model, the challenge belongs to a user in two different ways. If we go ahead and take a look at the schema here to remind ourselves, we can scroll on up to this challenge schema. It has a creator ID, so it's going to belong to a creator, which is a user. But then it also has this relationship that utilizes our pivot table of challenge user. And for that we're going to utilize a many to many relationship to describe that. So let's go ahead and first start by describing the creator ID here. So we'll jump back into our challenge model. And we already know that this is going to be a belongs to because that foreign key is directly on this table that we're describing and it belongs to a user. Then we'll declare the property name as created by and set the type to belongs to type of user and again updating that to use import type. For this one in particular, we are straying away from the default naming convention of user ID inside of the table. Again, that default naming convention being the parent model name plus primary key in a Camplas fashion. We're instead calling this creator ID. So, we're going to need to specify creator ID as the foreign key for our belongs to relationship here. Also note that we can describe the property name in itself any way that we like. So the column name is called creator ID, but we're calling our property inside of our model created by Lucid doesn't care what we call this property in itself. It just cares about the mapping as described here inside of our decorator. So on our decorator options, we need to specify the foreign key as creator ID so that it's able to correctly make that mapping to populate our created by property. Then finally, we have the many to many relationship for our pivot table. So there's a many to many decorator here just like all of the others. That takes in the model that we're relating this to, which is again going to be our user model. Then we can declare the property name. And again, this doesn't matter what we actually call it. All that matters is how we're mapping things inside of the decorator. So we can call this participants instead of users to be a little bit more descriptive. And then again, the type of this is going to match the decorator. So we have a many to many type of and it points to our user model. The many to many decorator has a little bit more of a different option set. So it has a local key still and that's the primary key of the current model that we're on. So our challenge model here that's going to be ID but it also has pivot based options as well. So we have a pivot foreign key. This is the pivot table's foreign key for the current model. So again we're inside of challenge. So this is going to be the challenge ID. And these pivot-based properties are the one and only that we're going to want to stray away and actually utilize the column as defined inside of the table because there's no model representation of these columns. It's just going to be described by this relationship here. Then we have the related side of this relationship. So we have the related key which is the primary key of the related model or user in this case that two is going to be ID by default. And then we also have a pivot related foreign key which just like pivot foreign key is going to be the pivot tables foreign key for the related model. And again we want to use the tables exact column name here. So it's going to be user ID. We can also change if needed the name of the actual pivot table via the pivot table attribute there as well. So we could set that to challenge user just like so. And then finally there's also pivot timestamps as an option in here that is a boolean. So we could set that to true. And what that does is it will automatically take care of the created at and updated at timestamps within our pivot table. Since this doesn't have a direct model representation, the column decorators here are a moot point because we won't be directly working with a model for this. So that is where this pivot timestamps option kind of comes into play to pick up the slack there. So I'm going to go ahead and remove all of these because we are already using all of these as defaults and don't need them. And finally, we also have the inverse side of this many to many that we could define as well on our user model. And this is going to be relatively similar. So we just need many to many. And instead of pointing to our user here, this instead points to a challenge. And we can keep the pivot timestamps option here as well. So we'll set that to true. And we'll declare this as challenges of type many to many type of and then import our challenge model. We can also go ahead and add in our has many relationship type for the challenge as well specific to the challenges that this user has created. So we can call this created challenges of type has many type of challenge and correct my arrow function there. And of course, we need to also specify that the foreign key for this relationship points to our creator ID because by default, this will try and look for a user ID on the challenge model. Now, I do see this asked a lot, so I'll go ahead and cover here quickly. Uh, you don't need to describe both sides of the relationships if you aren't going to work with both sides in code. For example, with our profile here, if we're only ever going to work with it through our user and not directly from the profile model, we don't need to describe the user on this profile model on itself. That can just be described on the user side. Finally, although we haven't talked about create, read, update, or delete CRUD operations, let's take a moment to learn about hooks. They are specialized static methods defined on our models that can hook into certain operations being performed within our models itself. For example, we can hook into the profile model to mutate it before it persists into the database. So if we go ahead and jump back into our profile model here. So we can go ahead and describe this as a static method. Call it on before save. And this will be provided our profile model of type profile here. Now the actual name that we give this method doesn't really matter. All that matters is that we decorate it with before save or any of the other available hookbased decorators that Adonjs provides. Any methods decorated with these hooks will then be run at the appropriate time regardless of the name that we provide them. The hooks will then provide the model. And since we're doing this before save, it's going to be the model before it's persisted into the database. If we were to do after save, which is the inverse side of this, it would be the model after it's persisted into the database. Inside of the method, then we can do whatever we really need to. So we can do if profile and then there's some internal based attributes that we can read from to see if a particular column is dirty or has been touched slashchanged. So we can check to see if the bio has been changed and check to see if there is not a bio. And if there isn't then we can set it before it's persisted into the database with a default value of something maybe like no bio. Now obviously that's probably not something that you would want to persist into the database but rather take care of at render time. But to give you an example of how that would work here. So something like this can be used for a number of the various hooks that Adonjs provides. So we have before save which will run before an insert or update operation. After save which will run after an insert or update operation. Before create which runs strictly before an insert operation. After create which runs strictly after an insert operation. Before update which runs before an update operation. after update which runs after an update operation, before delete which runs before a delete operation, after delete which runs after a delete operation. And there's also query based hooks that receive either the query before or the results of the query after. So we have before find after find before fetch after fetch before pageionate and after pageionate as

Get daily recaps from
Adocasts

AI-powered summaries delivered to your inbox. Save hours every week while staying fully informed.