The Module Architecture: Self-Contained Features Behind a Switch

By  Stephan Depta
Last updated October 19, 2026

Tracking, privacy handling, lazy loading, ads.txt management – none of these are part of AdPresso’s core. Each one is a module: a self-contained feature with its own directory, its own on/off setting, and a single entry point that is only called when that setting is on. When a module is switched off, the entry point is never called, so none of its runtime hooks are added. This article covers what a module consists of, how the ModuleManager decides which ones to load and when, and how modules influence the rendering pipeline without the core knowing they exist.

Why features are split this way

An ad plugin accumulates features that most sites only partly need. A site without AdSense has no use for the AdSense integration; a site whose consent banner blocks scripts on its own may not want AdPresso’s consent gate. If we wired all of that into the core unconditionally, every request would pay for it, and every feature would become a possible source of side effects.

The module split draws the line differently. The core – entities, conditions, the rendering pipeline – knows nothing about individual features. It exposes filters and actions at the points where a decision is made. A module is the thing that attaches to those points, and it attaches only when it is active. For anyone evaluating the plugin, that gives a practical guarantee: what a module does at runtime can be answered by reading the one method that adds its hooks, not by auditing the codebase.

What a module consists of

A module is a directory containing a service class, usually an admin/settings class, and a registration call. The Free plugin ships six, under modules/:

  • ads_txt — ads.txt management
  • adsense — the AdSense integration
  • adblocker_counter — counts visitors with an active ad blocker
  • asset_masking — rewrites the URLs of enqueued scripts and stylesheets (part of the ad-blocker settings)
  • tracking — impression and click tracking
  • privacy — the consent gate

Pro registers two more from pro/modules/: lazy_loading and fallback.

Note that the IDs use underscores while the directories use hyphens (modules/ads-txt/ registers ads_txt, modules/adblocker-asset-masking/ registers asset_masking). The ID is what counts everywhere else – in the activation check and in the name of the settings key.

Registration

Every module announces itself with one call to ModuleManager::register(). This is the complete registration of the Lazy Loading module:

ModuleManager::register(
	'lazy_loading',
	[
		'label'       => 'Lazy Loading for Ads',
		'description' => 'Improves page load speed by loading ads only when they enter the viewport.',
		'loader'      => fn() => LazyLoadService::get(),
		'register_ui' => [ LazyLoadAdmin::class, 'register_settings_ui' ],
		'setting'     => 'adpresso_settings_pro',
	]
);

The array accepts five keys, all optional:

  • label — a human-readable name. Defaults to the ID with its first letter capitalized. It appears in the admin-bar debug notices that list which modules are loaded.
  • description — a free-text description. Defaults to an empty string.
  • loader — a callable that boots the module. It is called only if the module is active. In every shipped module it does the same thing: fetch the service singleton, whose hooks() method adds the module’s filters and actions.
  • register_ui — a callable that registers the module’s settings UI. Unlike the loader, it is called for every registered module, and receives the module’s active state as a single bool argument. That asymmetry is deliberate: the switch that turns a module on has to be visible while the module is off.
  • setting — the name of the WordPress option that stores the module’s on/off state. Defaults to adpresso_settings_{id}.

Registering an ID a second time doesn’t fail. The later registration replaces the earlier one, and the runtime debugger records a notice saying so.

How activation is decided

ModuleManager::is_active( $id ) reads the option named by setting and looks for the key enable_module_{id} in it. A truthy value means active; a missing option or a missing key means inactive. Every module is therefore off until someone turns it on.

Two details are worth knowing. First, the check reads the option directly with get_option() rather than going through AdPresso’s settings class. Module state has to be known before the settings themselves are registered, so the manager can’t depend on them. Second, the option name is per module but not necessarily unique: tracking, privacy, adsense and ads_txt each have their own option and their own settings tab; the two ad-blocker modules share adpresso_settings_adblocker, and both Pro modules share adpresso_settings_pro. Several modules can live in one settings tab and still be switched independently, because the key inside the option carries the module ID.

is_active() is a public static method, and the rest of the plugin uses it wherever it needs to know a module’s state. The helper that resolves the global fallback item, for example, returns nothing when the fallback module is off – regardless of what is still stored in the settings.

Boot order

Module loading happens inside Plugin::load_integrations(), which runs on init. Schematically:

load repositories, register entities

require modules/loader.php          → every Free module calls register()
hook ModuleManager                  → loader() runs for each active module

load admin OR frontend integrations (pipeline runtime, metaboxes, ...)
ModuleManager::register_all_ui()    → register_ui( is_active ) for every module
register REST controllers, settings tabs, conditions, entity types

do_action( 'adpresso_load_integrations' )

The manager comes directly after the data layer and before every admin, frontend, and REST integration because of the dependency direction. Modules don’t get called by the core; they add callbacks to filters the core applies later. By the time the pipeline runtime, the metabox handlers or the REST controllers are set up, every active module has already attached itself, so nothing built afterwards can run ahead of a module’s callbacks.

UI registration is a separate, later pass. In practice register_ui either registers a settings tab of its own (tracking, privacy, adsense, ads_txt) or adds a section and options to an existing tab through the adpresso_tab_{tab_id}_sections and adpresso_tab_{tab_id}_options filters (the Pro modules).

Staying decoupled: attach, don’t get called

No core class references a module’s service class. The coupling runs entirely through hooks, and the clearest example is the SSR/JS decision of the rendering pipeline.

The Privacy module needs every ad that requires consent to be rendered in the browser, because consent is only known there. It gets that by adding a callback to adpresso_requires_frontend_logic in PrivacyService::hooks(). The callback leaves the value alone if JS output is already required, returns false if the item failed backend validation, and otherwise asks the module’s own consent policy whether this item needs consent. The pipeline applies the filter for every placement and doesn’t know who is answering. Switch the module off, and the callback is never added: the pipeline behaves as if the module had never been written.

The Fallback module shows the same pattern for data instead of a decision. The core assembles the global fallback configuration that is sent to the browser and passes it through adpresso_global_fallbacks; the module adds the privacy fallback to it. It adds the corresponding field to the placement edit screen through adpresso_placement_fallback_fields. Neither the frontend config builder nor the metabox knows that field comes from a module.

Modules are decoupled from each other in the same way. Lazy Loading and Privacy can both force JS output for the same placement without referencing one another, because each one only ever turns a false into a true and passes an existing true through. That convention – never override another callback’s true — is what makes independent modules composable, and it applies to your own callbacks too.

How Pro extends Free

Pro never modifies a Free class. It is loaded as an extension and uses the same two mechanisms as everything else.

Pro modules go through the same ModuleManager::register() call as Free modules. Pro boots on the adpresso.loaded action, which fires on plugins_loaded – before init, and therefore before the manager runs the loaders. From the manager’s point of view, there is no difference between a Free and a Pro module; the list is longer.

Pro’s additions to the Free registries – additional placement types, additional conditions, the Pro settings tab – are registered on adpresso_load_integrations, the action near the end of the sequence above. At that point, all Free registries exist and can be extended.

Pro can also extend a Free module. TcfVendorApi is the Pro part of the Free privacy module, and Pro only hooks it if ModuleManager::is_active( 'privacy' ) returns true. Without that check, switching Privacy off would leave part of its behavior running – which is exactly what the module switch promises not to do.

Building your own module

The same route is open to your own code: call AdPresso\Modules\ModuleManager::register() before AdPresso’s init callback runs — adpresso.loaded is where Pro does it – and keep to the shape the shipped modules use. That means a service class whose hooks() method is the only place that adds filters and actions, a loader that does nothing but instantiate it, and a register_ui callback that adds the toggle to a settings tab. The toggle has to end up as the key enable_module_{your_id} in the option you name in setting; the Pro modules, for example, add theirs to the Pro tab and register with 'setting' => 'adpresso_settings_pro'.

Keep the loader as the single entry point. Anything your module does outside of it – a hook added at file level, a REST route registered unconditionally – keeps running when the module is off, and has to check ModuleManager::is_active() itself.

One pitfall concerns per-entity settings rather than the module switch. If your module adds a toggle field to ads, groups or placements and reads it at runtime with get_meta(), pass false as the fallback, not the field’s registered default. MetaHandler::get() returns the fallback whenever the stored value is an empty string, because that is how WordPress reports a meta key that was never saved. A boolean false ends up in post meta as an empty string as well, so a toggle that was deliberately switched off and one that was never saved are indistinguishable when read back. A field’s default exists to pre-fill the edit form of a new entity; using it as the runtime fallback means a toggle with default => true can never be turned off. Treat the ambiguous case as off.

For the hooks themselves – parameters, return values and examples – see the filters and actions reference.

Stephan Depta

About the Author

Stephan Depta is a software engineer and WordPress developer with more than 12 years of experience building plugins, web applications, and custom WordPress solutions. He worked on Advanced Ads for several years, first as Lead Developer and later as Product Manager, combining hands-on plugin development with software architecture, performance optimization, and technical product strategy. Today, he is one of the developers behind AdPresso, where he focuses on building fast, modern, and reliable ad management tools for WordPress publishers.

This is AdPresso

Streamlined WordPress ad management, built on 15 years of expertise for serious monetization.
Features robust protection, targeting, A/B testing, diverse placements, and insightful tracking to spark your full revenue potential.

BlogStudies
AdPresso Logo