Loose Coupling Example: UserViewEntity + Subclasses

Example of how ClassFactory is used along with BaseEntity

The Genesis of UserViewEntity through CodeGen

In MemberJunction, the CodeGen utility plays a pivotal role by automatically generating strongly-typed subclasses for each entity, like UserViewEntity. These subclasses map directly to the database entities, enabling a strongly-typed programming experience. CodeGen enriches these subclasses with extensive JSDoc documentation, extracted from MemberJunction's metadata, thereby streamlining development and maintenance.

Subclassing the Generated UserViewEntity

Given the automatic generation of UserViewEntity, direct modifications should be strictly avoided to preserve the integrity and upgradability of the codebase. Instead, custom logic specific to each entity should be placed in a sub-class of the generated class. For example, with the User Views entity, this logic is packaged into a class called UserViewEntityExtended, code available here. This subclass extends the generated UserViewEntity, allowing developers to enhance functionality without tampering with the original autogenerated code. In the future if changes occur to the underlying database schema or meta-data inside MemberJunction, the CodeGen tool will regenerate the UserViewEntity class and those changes will automatically be available throughout the sub-class hierarchy including UserViewEntityExtended and classes derived from it.

UserViewEntityExtended implements a variety of different logic required for this entity. This entity represents the viewing system functionality within MemberJunction and needs to handle a number of scenarios. For example, anytime data changes within the view's metadata that would affect the filtering requirements for the underlying SQL statement, the where clause will require an update. Whenever this entity change occurs, we need to automatically keep the Where Clause in the view up to date. This is done automatically within the UserViewEntity_Extended class as shown here in the UpdateWhereClause() method shown below.

    public async UpdateWhereClause(ignoreDirtyState?: boolean) {
        if (this.CustomWhereClause && (this.CustomWhereClause === true || this.CustomWhereClause === 1))
            // if the CustomWhereClause is set to true or 1, we don't want to update the WhereClause
            return;

        // if we get here, we need to update the WhereClause, first check to see if we have a Smart Filter or not
        if (this.SmartFilterEnabled && (this.SmartFilterEnabled === true || this.SmartFilterEnabled === 1) &&
            this.SmartFilterPrompt && this.SmartFilterPrompt.length > 0) {
	          // MORE CODE HERE TO ACTUALLY IMPLEMENT THINGS CHECK OUT GITHUB TO SEE ALL THE CODE :)
        }
        else {
            this.WhereClause = this.GenerateWhereClause(this.FilterState, this.ViewEntityInfo);
        }
    }

The UpdateWhereClause() method is in turn called from an overridden Save() method in the class, shown below (note: the code will continue to change on Github so make sure to check the link provided above for the latest)

    override async Save(options?: EntitySaveOptions): Promise<boolean> {
        // we want to preprocess the Save() call because we need to regenerate the WhereClause in some situations
        if (!this.ID ||
            options?.IgnoreDirtyState || 
            this.Fields.find(c => c.Name.toLowerCase() == 'filterstate')?.Dirty ||
            this.Fields.find(c => c.Name.toLowerCase() == 'smartfilterenabled')?.Dirty ||
            this.Fields.find(c => c.Name.toLowerCase() == 'smartfilterprompt')?.Dirty) {
            // either we're ignoring dirty state or the filter state is dirty, so we need to update the where clause
            await this.UpdateWhereClause(options?.IgnoreDirtyState);
        }

        // now just call our superclass to do the actual save()
        return super.Save(options);
    }

There are several other examples of overridden base class methods within the UserViewEntityExtended sub-class. The point here is that using standard object-oriented programming (OOP) techniques like sub-classing give us a great way of extending functionality without fundamentally violating a key principle of OOP, encapsulation. The base class remains intact and can cleanly separate its logic from sub-classes.

To further illustrate the point, let's examine a sub-class of the UserViewEntityExtended class called UserViewEntity_Server

UserViewEntity_Server - a sub-class of a sub-class of a sub-class 😃

The MemberJunction viewing system supports a concept called Smart Filters. A smart filter is the ability for a user to describe the data they want to see in natural language and then MJ will convert this to a SQL where clause. To implement this, MJ needs to talk to a Language Model whenever a smart filter is created or updated in the system. Talking to a language model typically involves using an API of some sort. As a result, it is not something suitable for client-side code in a browser or similar envrionment and only should be done when the code is running on the server. As described in the Provider Architecture section, MemberJunction provides a consistent programming object experience regardless of where the code is executing. This provides a wide array of advantages for developing and maintaining software quickly and reducing error rates. It is important to note, however, that in some situations you need to be able to separate code that must only run on the server from code that can run anywhere. This is a good example of that scenario. For this reason, we have a sub-class of the UserViewEntityExtended class called UserViewEntity_Server.

The UserViewEntity_Server class, located here, emerges as a further subclass of UserViewEntityExtended, exclusively residing on the server side. This architectural decision separates the implementation of the Smart Filter feature, which necessitates server-side execution to interact with external APIs, notably those of Large Language Models (LLMs). In the server-only class we have code like the following which is capable of interacting with an LLM view the MemberJunction AI wrapper classes.

The server-only class provides an overridden implementation of a method called GenerateSmartFilterWhereClause which is stubbed out in the parent class (UserViewEntityExtended). Remember, the UserViewEntityExtended class can run on a client, server, anywhere, so it cannot make API calls. But, it does know about the concept of Smart Filters and realizes that in certain situations changes to the Smart Filter require an update to the SQL where clause. So, it provides an overridable stub function that is implemented by the server-only class. Additionally, the UserViewEntity_Extended class implements a read-only property called SmartFilterImplemented that flags if the current instance of the class supports generating smart filters or not. In the run-anywhere class, this property returns false, but in the server-only class, it is overridden to return true as shown below:

protected override get SmartFilterImplemented(): boolean {
  return true;
}

Loose Coupling Achieved Through ClassFactory

We've covered in the above the multi-layer hierarchy that's required in this paradigm to support the custom logic of a particular entity, in our example, the User Views entity. In addition we've covered how a server-only class can encapsulate the specifics of code that must run server-only, such as Smart Filter generation.

The key layer on top of this is to comprehend how the rest of the MemberJunction codebase (or your code) selects the right class at run time. What we're after is loose coupling, we don't want to manually select a given sub-class in various tiers of the application because that creates tight or direct coupling and makes the application not only harder to maintain but it also results in less flexibility.

This is where ClassFactory comes into play. The utility of ClassFactory becomes evident in this hierarchy, allowing for dynamic loading and substitution of these subclasses based on runtime conditions. This mechanism supports the seamless integration of custom logic and server-specific functionalities while maintaining a loosely coupled architecture. It exemplifies how MemberJunction achieves modularity and flexibility, enabling developers to adapt and extend the system with zero to minimal impact on existing code.

Here's how it works:

  • In each application environment like the MJ Explorer Angular app or in the MJ API server-side API app, a set of packages are imported through npm.
  • In the case of the MJAPI server-side app, the @memberjunction/server package is imported (npm/GitHub). @memberjunction/server is, of course, a server-only package and it in turn defines the UserViewEntity_Server class within it.
  • In contrast the MJ Explorer app does not import the @memberjunction/server package. It does import the @memberjunction/core-entities package which contains the UserViewEntityExtended class in it.

As a result of the above, the MJAPI app has loaded the @memberjunction/server package as well as the @memberjunction/core-entities package which contains sub-classes of the generated UserViewEntity class. In comparison the MJ Explorer app only has the @memberjunction/core-entities package. Each of the classes in question uses the @RegisterClass decorator that is part of the @memberjunction/global package which is used for registering sub-classes of a given base class. Here's an example from the generated UserViewEntity subclass (GitHub).

@RegisterClass(BaseEntity, 'User Views')
export class UserViewEntity extends BaseEntity {

As you can see, the @RegisterClass decorator was applied to the first-level sub-class of BaseEntity called UserViewEntity. This is done by the CodeGen utility automatically to register this first level sub-class.

Then, within the @memberjunction/core-entities package, the UserViewEntity_Extended class is defined and it registers itself with the same decorator as shown below (GitHub)

@RegisterClass(BaseEntity, 'User Views', 2) // 2 priority so this gets used ahead of the generated sub-class
export class UserViewEntityExtended extends UserViewEntity  {

The notable difference between the two class registrations is the use of the optional 3rd parameter in the decorator for the UserViewEntityExtended class which is a priority parameter. It works like this - at run time when you ask ClassFactory for a new instance of a given class it will use the highest registration. So if you ask for a BaseEntity sub-class for the User Views entity, you'll get, in the above example, UserViewEntityExtended since it has a higher registration than the generated sub-class (which doesn't specify a priority and therefore has a priority of 0).

Finally, the server-only @memberjunction/server package defines another layer of sub-class called UserViewEntity_Server and that class only exists in a package that will be imported on the server side. As a result, when the MJAPI project is loaded, the following class registration decorator will execute and register the UserViewEntity_Server class (GitHub).

@RegisterClass(BaseEntity, 'User Views', 3) // high priority to ensure this is used ahead of the UserViewEntityExtended in the @memberjunction/core-entities package (which has priority of 2)
export class UserViewEntity_Server extends UserViewEntityExtended  {

In this example, you see that we have a higher registration priority of 3 in the server-only class which will result in this class being instantiated by ClassFactory whenever someone asks for a new instance of a BaseEntity derived sub-class with a key of "User Views".

The result of all of this is that we have zero-knowledge of the actual class we're using in various places in the code base. We simply know that it is derived from the UserViewEntity class (or in some cases from the UserViewEntityExtended) class. As a result we achieve loose coupling and can also create extensibility. For example, let's say in your application you wanted to extend the functionality of Smart Filters in some ways. Perhaps there are certain cases where you want to use different or enhanced logic in this process. You could create your own UserViewEntity_Server_Custom class (name can be whatever you want) and sub-class the UserViewEntity_Server class and apply the @RegisterClass decorator to your new class with a higher priority, and import your package into the MJAPI run-time. By doing this, your new sub-class will automatically and transparently be used everywhere in the server with zero code changes required anywhere in the core MemberJunction codebase! Pretty awesome if we do say so ourselves 😃

Instantiation of a class

To provide an example of code where we are actually getting a new instance of a sub-class but we don't know which one we're getting, check out this code:

const md = new Metadata();
const viewEntity = await md.GetEntityObject<UserViewEntity>('User Views');

In the first line of code we're creating a new instance of the all-purpose Metadata object which in turn is wrapping the Class Factory. The actual implementation of GetEntityObject lives within the ProviderBase class (which is used by all provider sub-classes automatically) and looks like this:

    public async GetEntityObject<T extends BaseEntity>(entityName: string, contextUser: UserInfo = null): Promise<T> {
        try {
            const entity: EntityInfo = this.Metadata.Entities.find(e => e.Name == entityName);
            if (entity) {
                // Use the MJGlobal Class Factory to do our object instantiation
                try {
                    const newObject = MJGlobal.Instance.ClassFactory.CreateInstance<T>(BaseEntity, entityName, entity) 
                    if (contextUser)
                        newObject.ContextCurrentUser = contextUser;

                    return newObject;
                }
                catch (e) {
                    LogError(e)
                    throw new Error(`Entity ${entityName} could not be instantiated via MJGlobal Class Factory.  Make sure you have registered the class reference with MJGlobal.Instance.ClassFactory.Register(). ALSO, make sure you call LoadGeneratedEntities() from the GeneratedEntities project within your project as tree-shaking sometimes removes subclasses and could be causing this error!`);
                }
            }
            else
                throw new Error(`Entity ${entityName} not found in metadata`);
          } catch (ex) {
            LogError(ex);
            return null;
          }
    }

The important part here is Line 7 where we invoke the ClassFactory.CreateInstance() method which handles the actual instantiation.

Summary/Wrap-Up

Strongly-Typed Development:

By automating the creation of entity-specific subclasses, MemberJunction offers developers a strongly-typed interface to interact with database entities, reducing errors and improving code readability.

Automated Documentation:

Injecting JSDoc documentation directly into the subclasses enhances understandability and accessibility for developers, facilitating quicker onboarding and reference.

Modularity and Extendibility:

The subclassing strategy exemplifies a modular approach, where customizations and extensions are decoupled from autogenerated code, ensuring ease of maintenance and upgradeability.

Server-Side Enhancements:

Isolating server-specific logic in UserViewEntity_Server underscores a thoughtful separation of concerns, optimizing performance and security by leveraging server-side capabilities for complex operations like Smart Filters.

MemberJunction's architectural design, highlighted by the UserViewEntity hierarchy, showcases an advanced application of a loose coupling pattern using ClassFactory to achieve a scalable, maintainable, and loosely coupled system. The synergy between CodeGen's automated subclass generation and strategic subclassing facilitates a robust, developer-friendly platform that elegantly balances flexibility with structure.

You can use the same pattern with base/sub-classes and ClassFactory in your applications to achieve similar benefits!