I maintain a JS UI component library and a frontend web application following the general MVC architecture. There are basic views, complex views, and then controllers that bind the view logic to the model.
Basic components (views)
// The UI library is made of atomic components like the following one.
//
// * This could be as well a React component, a native web component, or any other framework.
// It accepts properties, renders a DOM element, and has a means of informing the world
// about user actions and that's all that matters. It's a framework-agnostic example.
// * Note: this is a pseudo-code to keep it simple.
class ButtonView extends BaseView {
constructor( type, cssClass, label ) {
this.type = type;
this.cssClass = cssClass;
this.label = label;
// ...
}
render() {
this.element = document.createElement( 'button' );
// ...
// Set DOM element attributes and children so it looks familiar
// <button type="{ type }" class="{ cssClass }">{ label }</button>
// ...
this.element.addEventListener( 'click' ), () => {
this.fire( 'click' );
} );
}
}
Complex web application views
// The web application composes UI library views (e.g. simple buttons ☝️)
// into complex user interfaces like forms, toolbars, etc.
//
// Note: this is a pseudo-code to keep it simple.
class SomeFormView extends BaseView {
// ...
render() {
// Use the basic component to as a building block for the complex view.
const button = new ButtonView( 'submit', 'foo', 'Send form' );
button.on( 'click', () => {
this.submitForm();
} );
button.render();
// ...inject the button into the form along with other components.
}
submitForm() {
this.fire( 'submit', {
/* Passing the form data along with the event */
} );
}
// ...
}
Web application controllers
// The controller glues the topmost view layer and the model together.
//
// Note: this is a pseudo-code to keep it simple.
class SomeController {
constructor() {
this.someFormView = new SomeFormView();
this.someFormView.render();
this.someFormView.on( 'submit', ( data ) => {
this.onSubmit( data );
} );
}
onSubmit( data ) {
// E.g. save data to a database. It doesn't really matter.
}
}
Problem
My business requirement is to make the above MVC architecture as open-ended and extendable as possible by integrators. Here are some details:
- The web application runs in a web browser.
- The integrators need an API to:
- Change the template (add/remove attributes, children, behaviors) for basic components like mentioned
ButtonView
. - Change the template (add/remove attributes, children, behaviors) for complex views like mentioned
SomeFormView
. - Remove, replace, or inject an extra logic to the
SomeController
. This is especially useful when they have already customized the views the controller would glue to the model.
- Change the template (add/remove attributes, children, behaviors) for basic components like mentioned
- The customization must be possible in the runtime (e.g. right before the application starts on the client). Build-time module replacement and similar solutions are, sadly, off the table.
- As a side note, there’s an underlying plugin system the integrators can use to extend the application. So there’s a central extension point.
I’m looking for good examples: existing frameworks, books, academic papers or just any sort of inspiration. It does not have to be web-based. I assume that native application developers who allow for 3rd-party plugins (extensions) are facing the same set of problems that I am.
Nothing pops into my head aside from:
- splitting views and controllers into atomic chunks (e.g. methods)
- providing some sort of view/controller registry the integrator may hook into before the UI is rendered (and displayed) to swap classes.
Are ideas?
2
Answers
I can help you approach this from an architects way of thinking which might point you in the right direction, but I haven’t been a hands-on JavaScript developer for years, so I can’t offer concrete code you can just implement.
I have two ideas for you: Principles & Patterns, and Lifecycle & Documentation.
Principles & Patterns
The SOLID principles are probably your best bet, particularly Open–closed, Liskov substitution and Dependency inversion.
Using those you should be able to develop some integration & extension patterns you can offer, e.g.:
Adding / removing children could be done by exposing a collection of child controls; I’m less clear on how you’d do that with behaviors & attributes, partially because I’m not sure how you’d offer that in a controlled way (I assume you have ‘internal’ behaviors & attributes you’d want to protect) – maybe some kind of facade?
Sounds like #2 or #3 above. You provide a vanilla implementation, they are able to replace it with their own. Maybe part of the definition for that is methods and/or base classes that ensure your ‘under the hood’ plumbing still works.
Alternatively, you could break-up the template into parts that can be individually changed or extended – but not the overall template.
I’m not sure how you could offer the ability to modify logic in a controlled way. I guess I’d need to know more like specific examples of what you were trying to do so I could better understand. If you think about cohesion: making changes to a sub-system’s inner-cohesion is usually a serious design task – opening that up to 3rd party modification would be hard as you risk violating the Open-closed Principle.
In data processing there’s an architectural style called Pipes and Filters; the idea is that you have sequential linear flow of "filters", and naturally these can be added to, etc. If your logic was sufficiently flow-orientated or modular you might be able to offer a way for changing those modules – this implies that the "wiring" of the logic was relatively simple, i.e. simple enough that you could expose it for limited re-organisation.
I guess one idea is to provide the logic as a set of base classes that are pre-wired, where each class has a specific job (they work cohesively). You could allow extension or replacement of specific classes as long as the signatures between them was preserved.
Lifecycle & Documentation
This is really about taking ideas from other framework implementations out there, e.g. the ASP.NET Page Lifecycle.
No, I’m not suggesting you re-implement that framework, but as someone who used to develop on that stack, I came to appreciate that to make the best use of it you really needed to understand the underlying page lifecycle – which also affected how you designed and implemented controls that ran on those pages.
There will be other similar lifecycles and technologies out there, so if you can’t bring yourself to study this one, find another one you prefer.
There’s two idea from this you might find useful:
Lastly, I’m sure you will have thought about it, but make sure you include things that help integrators debug. The could be some combination of documentation, logging, conventions, etc.
I am sure you have already thought through SOLID principles. The way you have described your code seems very similar to VS Code extensions framework.
splitting views and controllers into atomic chunks (e.g. methods)
The customization must be possible in the runtime
I have worked with React.js, Vue.js, Next.js, etc. So, how things are done there is something you can take inspiration from. We have concept of
props
,hooks
,lifecycle events
, etc.You might also want to draw inspiration from Modern editor systems like CKEditor, Visual Studio Code, or frameworks like Eclipse. Here’s some food for thought:
Use a plugin registry for each layer (views, controllers, and models). Plugins register extensions/modifications through lifecycle hooks, e.g.,
onBeforeRender
,onAfterRender
,onControllerInit
, etc.Example for
ButtonView
customization:Inject dependencies into components via a DI container. You can enable runtime customization by replacing dependencies. For example, allow integrators to replace the default
ButtonView
implementation:Some more ideas
express.js
For customizing controllers, you might want to derive inspiration from Vue.js custom event handlers.
Hope this helps. Happy to discuss more depending on which path suits you.