Skip to main content

Module architecture

A module in Exiry backend is full autonomous module with mainly 4 parts:

  • GraphQL Schema
  • GraphQL Resolvers
  • Helpers functions
  • Database model(s)

Files structure

# the folder must be named after the object
module-name
├── schema.js
├── resolvers.js
├── helpers.js
└── models
├── module.model.js
└── index.js

The core of the Exiry backend will scrap all folders in ./src/main/app/ for the modules, it will require the schema.js and resolvers.js to be included in the Apollo GraphQL server. If it finds a models folders, it will be scrapped for DB models.

Helpers

If you have a large set of helpers for the module, it is recommended to split the helpers file to a helpers folder

Naming scheme

Your files names needs to be always in lowercase's and separated by -, for example if the object if ActivityLog the folder name will be folder-log

GraphQL schema

A GraphQL schema declaration in on file, contains Types, Input definitions and extending Query and Mutation types. The schema needs to be following this pattern in most cases. If you think that this pattern does not fit your needs, please contact your Tech-lead.

Change the word Module by the module object of your module, for example Invoice.

When declaring types, please put the input of that type just below it. The input type name should be the type name with Input added at the end of the word (example: Module > ModuleInput)

Every mutation should always return the object mutated, if it is a delete mutation, please return the before deleted object.

Naming conventions

Every mutation and query starts with a lowercase letter.

In short, try to name your mutation verb first. Your mutation represents an action so start with an action word that best describes what the mutation does.

Names like createUser, likePost and editComment are preferable to names like userCreate, postLike, commentUpdate, and userFeedReload.

However, many use-case have mutations that do not map directly to actions that can be performed on objects in your data model. For instance, say you were building a password reset feature into your app. To actually send that email you may have a mutation named: sendPasswordResetEmail.

Don’t be afraid of super specific mutations that correspond exactly to an update that your UI can make. Specific mutations that correspond to semantic user actions are more powerful than general mutations. This is because specific mutations are easier for a UI developer to write, they can be optimized by a backend developer, and only providing a specific subset of mutations makes it much harder for an attacker to exploit your API.

"""Module type"""
type Module implements AsyncDataNode {
id: ID
title: String
content: String
amount: Int
categories: [ModuleCategory]
tags: [ModuleTags]
createdAt: Date
updatedAt: Date
"""Other attributes"""
}

input ModuleInput {
id: ID
title: String
content: String
amount: Int
categoriesIds: [Int]
tagsIds: [Int]
}

"""Module sub 1 type"""
type ModuleCategory {
id: ID!
name: String
description: String
}

input ModuleCategoryInput {
id: ID!
name: String
description: String
}

"""Module sub 2 type"""
type ModuleTags {
id: ID!
name: String
active: Boolean
}

input ModuleTagsInput {
id: ID!
name: String
active: Boolean
}

type ModuleFilter {
name: String
data: [ModuleFilterData]
}

interface ModuleFilterData {
id: ID
}

type ModuleFilterDataCategory implements ModuleFilterData {
id: ID
name: String
description: String
}

type ModuleFilterDataTag implements ModuleFilterData {
id: ID
name: String
active: Booleans
}

enum ModuleFilterSlugs {
CATEGORIES
TAGS
USER
}

extend type Query {
module(id: ID): Module @hasAccess(slug: "module", scope: "view", own: true)

modules(
ids: [ID]
search: String
filters: [ArrayFilterInput]
pagination: PagingOptions
): AsyncData @hasAccess(slug: "module", scope: "view", own: true)

moduleToFile(
ids: [ID]
search: String
filters: [ArrayFilterInput]
pagination: PagingOptions
format: FileFormatInput
): String @hasAccess(slug: "module", scope: "view", own: true)

moduleFilters(
slugs: [ModuleFilterSlugs]
search: String
limit: Int
filters: [ArrayFilterInput]
): [ModuleFilter]

moduleCatagories(filters: [ArrayFilterInput]): [ModuleCategory]
@hasAccess(slug: "module", scope: "view", own: true)

activitiesTypes(filters: [ArrayFilterInput]): [ModuleTag]
@hasAccess(slug: "module", scope: "view", own: true)
}

extend type Mutation {
createModule(input: ModuleInput): Module
@hasAccess(slug: "module", scope: "create")
editModule(id: ID, input: ModuleInput): Module
@hasAccess(slug: "module", scope: "edit")
deleteModule(id: ID): Module
@hasAccess(slug: "module", scope: "delete")

createModuleCategory(input: ModuleCategoryInput): ModuleCategory
@hasAccess(slug: "module", scope: "create")
editModuleCategory(id: ID, input: ModuleCategoryInput): ModuleCategory
@hasAccess(slug: "module", scope: "edit")
deleteModuleCategory(id: ID): ModuleCategory
@hasAccess(slug: "module", scope: "delete")

createModuleTag(input: ModuleTag): ModuleTag
@hasAccess(slug: "module", scope: "create")
editModuleTag(id: ID, input: ModuleTag): ModuleTag
@hasAccess(slug: "module", scope: "edit")
deleteModuleTag(id: ID): ModuleTag
@hasAccess(slug: "module", scope: "delete")
}

GraphQL resolvers

The resolvers file act as a Controller-like in an MVC architecture. It is responsible to receive the request (query or mutation) and revolves it. If there is any validations, check-ups or logs the resolver is required to handle and sanitize the data.

A resolvers file is typically looks like this:

const { ApolloError } = require("apollo-server-express");
const helpers = require("./helpers");
const { requestedFields } = require("../../../helpers/graphql.helper");

const resolvers = {
Query: {
async module(_, args, { user, privilege }, info) {
const { id } = args;
// Check for rights
const filters = [];
if (privilege === "can_view_own") {
filters.push({ name: "USER", value: user.id });
}
// Render the requested fields for optimized SQL queries
const attributes = requestedFields(info);
// Get the data
const modules = await helpers.getData([id], { filters, attributes });
return modules[0] || null;
},
async modules(_, args, { user, privilege }, info) {
const { ids, pagination, search, filters } = args;
// Check for rights
let filters = [];
if (privilege === "can_view_own") {
filters = filters.filter(({ name }) => name !== "USERS");
filters.push({ name: "USER", value: user.id });
}
// Render the requested fields for optimized SQL queries
const attributes = requestedFields(info);
return helpers.getData(ids, {
pagination,
search,
filters,
attributes,
});
},
// And other queries resolvers
},
Mutation: {
async createModule(_, args, { user }, info) {
const { input: data } = args;
try {
const id = await helpers.create(data);
// Render the requested fields for optimized SQL queries
const attributes = requestedFields(info);
// Get the data
const modules = await helpers.getData([id], { attributes });
return modules[0] || null;
} catch (error) {
const error = "Error creating the requested module.";
throw new ApolloError(error, `SERVER_ERROR_CREATING_MODULE`);
}
},
// Other mutation resolvers
},
};

module.exports = resolvers;

Resolver function

A resolver function is async function (or Promise), that resolves the data or throws and error. For Exiry backend is using Apollo Server, it is required to use ApolloError to throw error for better error handling.

The resolver function receives 4 parameters in its header (parent, args, context, info):

  • parent: is the parent field in GraphQL if there is
  • args: the arguments passed in the Mutation of Query of the field, for example, when executing query { invoice(id: 4) }, the args object passed to the invoice resolver is { "id": 4 }.
  • context: the global context of the GraphQL
  • info: misc information about the GraphQL request like requested fields

You can check this link for the Apollo documentation.

Resolvers context

The Exiry backend injects multiple object in the context of GraphQL for a better developer experience.

AttributeTypeDescription
userObjectthe logged user, null if no user is logged, contains {id, email, fullName}
privilegeStringthe used privilege to gain access ("can_view", "can_view_own", "can_edit", ....)
ipStringthe user ip v4 address
agentStringthe user agent

Resolvers helpers

Exiry backend packages a lot of resolvers purposed functions:

  • createActivityLog(description: string, userId: Int): a Promise that is used to log a user activity like Content is updated (id: ${content.id}).
  • requestedFields(info: Object): return an object of requested fields

Helpers functions

Helpers function are like Models in the MVC architecture, they communication with data sources like Database and return data or throw errors. Helpers function are not aware of access or authentication, that's the role of the resolvers.

Database models

If your app uses a SQL database, Sequelize is used by Exiry backend for handling the database.

You're required to declare your database models in the ./module-name/models/ folder, writing files named english single form of the object with Camel case (example: Invoice) and add to it the extension .model.js so for our example the file name will be Invoice.model.js or with more models like InvoiceCategory.model.js.

File and naming specification

A model file exports a function with the header (sequelize, DataTypes). The object defined by sequelize.define needs to be a Camel case object. The first argument of the function is the name of the model, with is also Camel case and mostly the same as the object name.

The attributes starts with a lowercase then camel case for the rest of the name but the database name need to be all-lowercase with lower-dash as separation (example: userId for the attributes name, but user_id for the field). This is done with the field attribute

{
userId: {
field: "user_id",
type: DataTypes.INTEGER(11).UNSIGNED,
allowNull: false,
},
}

Models association are declared in a associate function.

Check the sequelize documentation for more details.

Data types

Strings

DataTypes.STRING; // VARCHAR(255)
DataTypes.STRING(1234); // VARCHAR(1234)
DataTypes.STRING.BINARY; // VARCHAR BINARY
DataTypes.TEXT; // TEXT
DataTypes.TEXT("tiny"); // TINYTEXT
DataTypes.CITEXT; // CITEXT PostgreSQL and SQLite only.

Boolean

DataTypes.BOOLEAN; // TINYINT(1)

Numbers

DataTypes.INTEGER; // INTEGER
DataTypes.BIGINT; // BIGINT
DataTypes.BIGINT(11); // BIGINT(11)

DataTypes.FLOAT; // FLOAT
DataTypes.FLOAT(11); // FLOAT(11)
DataTypes.FLOAT(11, 10); // FLOAT(11,10)

DataTypes.REAL; // REAL PostgreSQL only.
DataTypes.REAL(11); // REAL(11) PostgreSQL only.
DataTypes.REAL(11, 12); // REAL(11,12) PostgreSQL only.

DataTypes.DOUBLE; // DOUBLE
DataTypes.DOUBLE(11); // DOUBLE(11)
DataTypes.DOUBLE(11, 10); // DOUBLE(11,10)

DataTypes.DECIMAL; // DECIMAL
DataTypes.DECIMAL(10, 2); // DECIMAL(10,2)

Unsigned & Zerofill integers - MySQL/MariaDB only
In MySQL and MariaDB, the data types INTEGER, BIGINT, FLOAT and DOUBLE can be set as unsigned or zerofill (or both), as follows:

DataTypes.INTEGER.UNSIGNED;
DataTypes.INTEGER.ZEROFILL;
DataTypes.INTEGER.UNSIGNED.ZEROFILL;
// You can also specify the size i.e. INTEGER(10) instead of simply INTEGER
// Same for BIGINT, FLOAT and DOUBLE

Dates

DataTypes.DATE; // DATETIME for mysql / sqlite, TIMESTAMP WITH TIME ZONE for postgres
// DATETIME(6) for mysql 5.6.4+. Fractional seconds support with up to 6 digits of precision
DataTypes.DATE(6);
DataTypes.DATEONLY; // DATE without time

Check the Sequelize documentation https://sequelize.org/master/manual/model-basics.html#data-types

Example file

Here is an example of a model:

"use strict";
module.exports = (sequelize, DataTypes) => {
const InvoiceCategory = sequelize.define(
"InvoiceCategory",
{
id: {
type: DataTypes.INTEGER(11).UNSIGNED,
primaryKey: true,
allowNull: false,
autoIncrement: true,
},
name: {
type: DataTypes.STRING(50),
allowNull: false,
},
description: {
type: DataTypes.STRING,
allowNull: true,
},
},
{
tableName: "tbl_activity_log",
}
);
InvoiceCategory.associate = function (models) {
// Association goes here
// Check https://sequelize.org/master/manual/assocs.html
};
return InvoiceCategory;
};