@effijs/common
v2.0.15
Published
An efficient, modern way to organize the business logic of an application(@effijs/common)
Downloads
22
Readme
Introduction
EffiJS is a framework for building efficient, scalable JavaScript-based frontend applications. It uses progressive JavaScript, is built with and fully supports TypeScript, and combines elements of Object-Oriented Programming (OOP). The framework leverages decorators, metaprogramming, and a modular project structure to create maintainable, well-organized applications.
Philosophy
The main challenge in frontend development is establishing a clear separation between business logic and UI elements. In many projects, these concerns become entangled, making the codebase increasingly difficult to maintain as the application grows.
Every frontend project needs to retrieve data from a source (typically a backend service) and present it to users in a meaningful way. This process seems straightforward for small projects, but as complexity increases, developers often find themselves implementing global state management solutions to update the UI when data changes, and creating reusable components for both UI elements and business logic.
The primary purpose of EffiJS is to enforce a clean separation between business logic and UI components. The typical application flow is:
- User loads the application
- When a specific section needs to be displayed, the application sends requests to the server to retrieve necessary data
- The data is processed into the required format and displayed. Additional data processing may occur, allowing interaction with other application entities
EffiJS provides a structured approach through clearly defined project layers:
- Module: An encapsulated project entity containing controller, business service, storage, API service, data transformation, data validation, and types
- Controller: Manages service logic
- Service: Contains the entity's business logic
- Storage: Stores data used throughout the project
- API Service: Handles data retrieval using your preferred transport method
- Data Transformation: Converts server responses into data objects that match your requirements
- Data Validation: Creates class validators with conditions for form data
- Types: Defines necessary types for the entity model
The architecture is heavily inspired by NestJS, metaprogramming principles, and TypeScript decoration context.
Project Structure Example
src
├── assets
│ ├── fonts
│ └── icons
├── modules
│ ├── app
│ │ ├── components
│ │ ├── screens
│ │ ├── app.controller.ts
│ │ ├── app.store.ts
│ │ ├── app.module.ts
│ │ └── index.js
│ ├── car
│ │ ├── components
│ │ ├── screens
│ │ │ ├── Car.details.tsx
│ │ │ ├── Car.list.tsx
│ │ │ ├── Car.form.tsx
│ │ │ └── Car.item.tsx
│ │ ├── car.api.ts
│ │ ├── car.controller.ts
│ │ ├── car.service.ts
│ │ ├── car.store.ts
│ │ ├── car.dto.ts
│ │ ├── car.validator.ts
│ │ ├── car.module.ts
│ │ └── index.js
│ │── useModules.ts
│ └── index.ts
└── index.jsLayers
EffiJS provides a structured approach to frontend development through clearly defined layers. This document outlines each layer and its purpose within the framework.
Controllers
Controllers are used to process user actions and return the results of those actions. A controller can have multiple functions. The primary requirement for controllers is to remain "thin" - they should connect necessary actions and effects, while delegating the core business logic to services.
To create a basic controller, we use classes and decorators. Decorators link classes with the necessary metadata.
// @modules/car/car.module
import {Entity} from "@effijs/common";
import {CarController} from "./car.controller";
// import { UserStore } from "./car.store";
@Entity.Decorators.Module()
export class CarModule {
constructor(
public controller: CarController,
// public store: CarStore,
) {
}
}
// @modules/car/car.controller
import {Entity, Store} from '@effijs/common';
import {CarService} from '@modules/car/car.service';
import {ICarParams} from '@modules/car/car.types';
import {CatchError} from '@utils/CatchError';
@Entity.Decorators.Controller()
export class CarController {
constructor(private service: CarService) {
}
@CatchError
applyFilters(filterStore: Store.Form) {
const {error, data} = filterStore.validate();
if (error) {
return;
}
this.service.setFilters(data as ICarParams);
return this.getCars();
}
@CatchError
async getCars() {
await this.service.get();
}
}Services
Services are used to process the core business logic of an entity, interact with the server, retrieve and format data, and save it to a local store for subsequent display on the user interface.
import {Entity} from "@effijs/common";
import {CarApi} from "./car.api";
import {CarStore} from "./car.store";
import {transformCar} from "./car.dto";
import {ICar, ICarFilters} from "./car.types";
@Entity.Decorators.Service()
export class CarService {
constructor(
private api: CarApi,
private store: CarStore,
) {
}
setFilters(filters: ICarFilters) {
this.store.set("filters", filters);
}
async get() {
const filters = this.store.getByPath<ICarFilters>("filters");
const res = await this.api.getAll(filters);
const ids = res?.map((data) => {
const car = transformCar(data)
this.store.set(`car.${car.id}`, car);
return car.id;
});
if (ids) {
this.store.set('ids', ids);
}
}
async getInfo(id: number | string) {
const res = await this.api.getById(id);
this.store.set(`car.${id}`, res.data);
}
async add(data: ICar) {
await this.api.create(data);
await this.get();
}
async delete(id: number | string) {
await this.api.delete(id);
await this.get();
}
}Stores
Stores are used to maintain the general state of the application, make changes, and update the display on the user interface.
Example of creating a store:
import {Store} from "@effijs/common";
export class CarStore extends Store.Common {
}Let's explore the store's functionality and how to use it:
Get Data
import {ICar} from "./car.types";
const initial = {
id1: {
id: 'id1',
name: 'Car',
price: 200000,
category: {
name: 'Category 1'
},
createdAt: 'Date'
},
ids: ['id1']
}
const carStore = new CarStore(initial);
// Get data
// get whole car data by id
const car = carStore.get<ICar>('id1');
// get some car data by id
const {name, price} = carStore.get<Pick<ICar, 'name' | 'price'>>(['id1.name', 'id1.price']);
// use alias when field names is equal
const {
name,
categoryName
} = carStore.get<Pick<ICar, 'name'> | 'categoryName'>(['id1.name', {categoryName: 'id1.category.name'}]);
// In previous cases, get always returns an object, which isn't always convenient.
// If you need to get data directly by path, use the following method.
// Note: In this case, you get data for only one path.
const name = carStore.getByPath('id1.name');Set Data
// Set Data
carStore.set('id1.name', 'Car Updated');
// Using function updater
carStore.set('id1', (prevValue) => ({
...prevValue,
...newValue
}));
// The main idea of making changes in the store is the absence of JavaScript errors when accessing undefined values, for example:
carStore.set('filters.name', 'Car');
carStore.set('filters.order.by', 'createdAt');
carStore.set('filters.order.sort', 'asc');
// And in store it will be as
const store = {
id1: {...},
ids: ['id1'],
filters: {
name: 'Car',
order: {
by: 'createdAt',
sort: 'asc'
}
}
};
// This approach gives freedom to developers but requires understanding that data can be overwritten in a different format,
// for example, from string or number types to object
// Working with arrays
// Push new item
store.set('array', (prev) => {
if (Array.isArray(prev)) {
prev.push(newItem);
return prev;
}
return [newItem];
});
// Update array element
store.set(`array[${index}]` /* or `array.${index}`*/, updateItemData);
// Update part of object
store.set(`array[${index}].key` /* or `array.${index}.key`*/, updateItemKeyData);To track changes in the store, we use subscriptions to variables we want to observe:
const unsubscribe = carStore.subscribe('id1', (data) => {
// Handle updated data
});
// Emit updates in listener
carStore.set('id1.name', 'Car 2');
// later in code clear listener
unsubscribe();In some cases, we can manage the notification of child and parent subscribers. For this, we use a third parameter in the set method:
interface ISetPropagateOptions {
notifyChild?: boolean;
notifyParent?: boolean;
deferParentExecution?: boolean;
deferChildExecution?: boolean;
}The default values are as follows and their configuration is sufficient in most cases:
const DEFAULT_OPTIONS = {
notifyChild: true, // Notify child subscribers
notifyParent: true, // Notify parent subscribers
deferParentExecution: false, // (PRO) Notify about updates in the next CallStack (Macrotask Queue), for example, if you need to wait for the completion of the current Microtasks Queue
deferChildExecution: false, // (PRO) Similar to deferParentExecution
};Now we can configure subscriber notifications. For example, in this case, notification about data updates in the id1 object will not be sent to parent subscribers:
// Also you can manage way of propagation by setting 3rd parameter called options
carStore.set('id1.name', 'Car 2', {
notifyParent: false,
});Delete Data
To delete data, use the following operator. Also, you don't need to worry about active subscribers to this variable. Subscribe/Unsubscribe can be done even if the data doesn't exist in the store.
carStore.delete('filters.name');Linking
Another important feature of the store is the ability to link store functionality to a path. This is especially useful when you have a complex object structure in the store and separate functions working with individual elements of this store. Instead of specifying the full path to the value each time, you can work with just the part of the store that contains the relevant data.
const store = new Store.Common({
name: 'Name',
description: 'Description',
categories: [
{name: 'Category 1', tags: ['tag A', 'tag B']},
{name: 'Category 2', tags: ['tag C']}
]
});
function categoryTags(store: Store.Common) {
// We can do any actions that can be provided by store but in scope of linked path of the store
// store.set('0', (tag) => tag.toUpperCase()) // tag A -> TAG A, tag C -> TAG C
return store.getByPath('tags')
}
const {categories} = store.get<{ categories: ICategory[] }>('categories');
categories.map((_, index) => categoryTags(store.link(`categories[${index}]`)));Form Store
A special store class that works with forms, including field validation and error display. The validator expects a class validator with a described data structure.
Example of a class validator:
import {Validator} from "@effijs/common";
@Validator.Decorators.Options({
skipMissingProperties: true,
})
export class CarFormValidator {
@Validator.Decorators.Property()
@Validator.Decorators.Validator(
(value) => value.length >= 2,
"Title must be at least 2 characters long",
)
title?: string;
@Validator.Decorators.Property()
@Validator.Decorators.Validator(
(value) => value && value <= new Date().getFullYear(),
"Must be a valid year",
)
year?: number;
@Validator.Decorators.Property()
@Validator.Decorators.Validator(
(value) => value?.length > 0,
"Should have at least one category",
)
@Validator.Decorators.Type(() => Category)
categories?: Category[];
}
class Category {
@Validator.Decorators.Property()
@Validator.Decorators.Validator(
(value) => value.length >= 2,
"Category name must be at least 2 characters long",
)
name?: string;
}Now we can create a form store and update data:
const formStore = new Store.Form(initial, CarFormValidator);
formStore.data.subscribe('name', (name) => {
// update UI element
});
formStore.error.subscribe('name', (name) => {
// update UI element to show validation error
});
formStore.data.set('name', 'Car');
// ...To validate the form, we execute the validate method and send the data if validation is successful:
function submit(formStore: Store.Form) {
const {validated, error, errorPathsWithoutSubscriptions} = formStore.data.validate();
if (error) {
// No action needed if all form fields have subscriptions for displaying errors
// If not, we can view errors for which there were no subscriptions
if (errorPathsWithoutSubscriptions?.length) {
showGeneralFormError();
}
return;
}
fetch('https://your.com/api/server', {method: 'POST', body: validated})
}
submit();Transform Data
Transform Data is used for convenient representation of data received from the server. It also provides additional functionality to simplify working with data. This approach uses decorators. Currently, the following decorators are available for full functionality:
- Property - mandatory.
@Property()
firstName
:
string;It's also possible to change property names:
@Property('first_name')
firstName
:
string;- Modify - for changing/transforming data of this parameter.
@Property()
@Modify((value) => value.toUpperCase())
name
:
string;- Type - binding a type to a variable. Note: the type class name must start with a capital letter.
@Property()
@Type(Category)
categories
:
Category[];Example:
import {Transform} from '@effijs/common';
import dayjs from 'dayjs';
const {Property, Type, Modify} = Transform.Decorators
class Car {
@Property('car_name')
name: string;
@Property()
description: string;
@Property('discount_by_date')
@Modify((value) => dayjs(value).format('DD MMM, YYYY'))
discountByDateFormat: string;
@Property('discount_by_date')
discountByDate: string;
get isDiscountActive() {
return dayjs().isBefore(this.discountByDate);
}
@Property()
@Type(Category)
categories: Category[];
}
class Category {
@Property()
name: string;
}
const data = {
car_name: 'Car',
description: 'Car description',
discount_by_date: dayjs().add(2, 'days').toISOString(),
categories: [{name: 'category 1'}]
}
const car = Transform.plainToClass(data, Car);
console.log(car instanceof Car); // trueValidate Data
Validate Data is necessary for validating input data. It's commonly used in forms and provides separated data handling logic. It's built on the use of decorators.
The following decorators are available:
- Options - class decorator for configuring whether to keep object fields not declared in the validator class.
@Options({
skipMissingProperties: true, // TRUE - removes all fields not declared in the validator class (whitelist)
})
class CarFormValidator {
}- Property - mandatory. The passed argument changes the field name to the specified one.
@Property('alias')
firstName
:
string;- IsDefined - Checks that the field has a value.
@Property()
@IsDefined() // no data - got error 'field is required'
firstName
:
string;
@Property()
@IsDefined('Last Name is required') // Better output text
lastName
:
string;- Validator - Manual validation process.
@Property()
@Validator((value) => value?.length > 10, 'First Name should be at least 10 chars long')
firstName
:
string;- Regexp - Validation by pattern.
@Property()
@Regexp(/^\d{6}/, 'Code should consist of 6 digits')
code
:
string;- Type - binding a type to a variable.
@Property()
@Type(() => Category)
categories
:
Category[];Example of validation implementation and usage:
import {Validator} from '@effijs/common';
@Validator.Decorators.Options({
skipMissingProperties: true,
})
export class CarFormValidator {
@Validator.Decorators.Property()
@Validator.Decorators.Validator(
(value) => value.length >= 2,
"Title must be at least 2 characters long",
)
title?: string;
@Validator.Decorators.Property()
@Validator.Decorators.Validator(
(value) => value && value <= new Date().getFullYear(),
"Must be a valid year",
)
year?: number;
@Validator.Decorators.Property()
@Validator.Decorators.Validator(
(value) => value?.length > 0,
"Should have at least one category",
)
@Validator.Decorators.Type(() => Category)
categories?: Category[];
}
class Category {
@Validator.Decorators.Property()
@Validator.Decorators.Validator(
(value) => value.length >= 2,
"Category name must be at least 2 characters long",
)
name?: string;
}
const data = {
title: 'Title',
year: '2028',
categories: [{name: 'Category 1'}]
}
const {validated, error} = Validator.validateByClass(data, CarFormValidator);
if (error) {
showError();
return;
}
fetch('https://project.com/api/entity', {method: 'post', body: validated});Request Builder
The Request Builder provides a structured approach to API communication using decorators and metaprogramming. This approach is used with server requests and supports RESTful methods.
The main decorators include:
- RequestBuilder - Class decorator for basic endpoint configuration and versioning:
@RequestBuilder({
endpoint: '/api/{version}/cars', // forming the path for the request
version: 'v1', // version support
apiServiceName: {
default: 'callRequest', // function that will be called by default for this service
},
})
class CarApiService {
}- RESTful method decorators: Get, Head, Post, Put, Patch, Delete, Options
@Get()
get()
{
}
// When calling the get() function, it will prepare the data:
{
method: 'get',
url
:
'api/v1/cars'
}
@Get(undefined, {version: 'v2'}) // you can directly change the api version
get()
{
}
// When calling the get() function, it will prepare the data:
{
method: 'get',
url
:
'api/v2/cars'
}- UrlParam - Provides the ability to bind URL parameters:
@Get('{id}')
getById(@UrlParam('id')
id: string | number
)
{
}
// When calling getById(2), it will prepare the data:
{
method: 'get',
url
:
'api/v1/cars/2'
}
@Put('{id}/category/{categoryId}')
updateCarCategory(
@UrlParam('id')
id: number,
@UrlParam('categoryId')
categoryId: number,
@Data
data: ICategory
)
{
}
// When calling updateCarCategory(2, 55), it will prepare the data:
{
method: 'put',
url
:
'api/v1/cars/2/category/55'
}- Params - Forming data for Query Parameters. If the decorator has no arguments, it merges with the global params object. The passed argument is the name of the field in the params object to which the data will be bound:
@Get('{id}')
getById(
@UrlParam('id') @Params('carId')
id: string | number,
)
{
}
// When calling getById(2), it will prepare the data:
{
method: 'get',
url
:
'api/v1/cars/2',
params
:
{
carId: 2
}
}- Data - Forming data for the request body:
@Post('{id}')
createCardCategory(
@UrlParam('id') @Data('carId')
id: number,
@Data()
data: ICategory,
)
{
}
// When calling create(2, {name: 'Category 3'}), it will prepare the data:
{
method: 'post',
url
:
'api/v1/cars/2',
data
:
{
carId: 2,
name
:
'Category 3'
}
}Middleware
Middleware provides a convenient way to organize the step-by-step separation of task execution flow into logical components of different services responsible for targeted business logic. The main scheme of the Middleware flow is:
Middleware 1 -> Middleware 2 -> function() {} => Middleware 2 => Middleware 1
Using middleware, you can prepare data for the main action and then verify the results.
Example:
const Logger: MiddlewareHandler<unknown, { logLevel: string }> = async (ctx, next, options) => {
console.log('>>> LOGGER', ctx, options);
const res = await next(ctx);
console.log('<<< LOGGER', res, options);
return res;
};
const Config: MiddlewareHandler<unknown, { option: number }> = async (ctx, next, options) => {
console.log('>>> Config', ctx, options);
const res = await next({...(ctx || {}), ...options});
console.log('<<< Config', res, options);
return res;
};
const middleware = new Middleware();
middleware.use('logger', Logger, {logLevel: 'error'});
middleware.use('config', Config, {option: 1});
async function doWork(data: any) {
await timeout(100);
return {data};
}
const middlewareFlow = middleware.get([
['logger', {color: 'red'}],
['config', {data: 2}],
]);
const execute = middlewareFlow(doWork)
await execute({key: 1});
// will return { data: { key: 1, option: 1, data: 2 } }Logger
The Logger service prepares a nicely formatted structure of log messages. The logging service can be overridden by another logging service. By default, the logging service is the console.
import {Logger} from '@effijs/common'
const logger = new Logger('Car Service');
logger.log('Created!') // will output '[Car Service] - Created! (+0ms)'The Logger interface provides methods for different log levels:
export interface ILoggerService {
log(entity: string | undefined, message: string, ...args: any[]): void;
warn(entity: string | undefined, message: string, ...args: any[]): void;
error(entity: string | undefined, message: string, ...args: any[]): void;
}You can configure the Logger with options:
export interface IOptions {
withTimestamp?: boolean;
}
// Usage
const logger = new Logger('Car Service', {withTimestamp: true});To define a custom logger service:
Logger.defineLoggerService(customLoggerService);