Framework-Driven Development

Framework-Driven Development

This post was NOT written by AI.
I only used AI to help refine my grammar because I am not a native English speaker.


TLDR

Framework-Driven Development can be extremely useful in the AI era, when we generate code rather than write it by hand. This idea assumes that, to ensure code quality and comply with Salesforce limits, we shift that responsibility from developers and AI agents to frameworks, libraries, and the development process itself.

The process should provide a set of rules and principles that guide developers and AI agents and help prevent mistakes. In this way, the system architecture can continue to follow sound design principles, regardless of the developer’s level of knowledge or the AI model being used.

Framework-Driven Development is based on a few assumptions:

  1. Frameworks should be idiot-proof (and AI-proof).
  2. Generated code should be optimized with platform limits in mind.
  3. Framework design should enforce code quality.

These assumptions are described below and illustrated with real-world examples.

Introduction

Generating code is simple; software engineering is NOT. Engineering is all about thoughtful principles and strong rules that provide a clear and structured way of making decisions. Without them, code can quickly become messy. Software engineering has already introduced different approaches that can guide our work. You’ve probably heard about:

… and more.

All of them are great when used correctly and help ensure code quality. However, in the AI era, new approaches can be introduced on top of the existing ones. One of my colleagues, Constantin Schlusche, introduced some time ago a new concept called Context-Driven Delivery, which perfectly solves some of the problems that vibe coding and AI create.

Today, I would like to introduce a new one called Framework-Driven Development. It can be treated as a variation of Framework-oriented design, but it’s BETTER!

Framework-Driven Development is a concept that we have been developing in Apex Fluently for the last couple of years.

Motivation

AI feels like magic – that’s true. You can generate code faster than ever before, and what’s even more interesting, you can generate that code without understanding it at all – you know it as vibe coding. However, LLMs are statistical beasts and rely on probability (we are not in the AGI era yet 🙂 ). LLMs were trained on a massive amount of code you can find on the internet (GitHub, forums, etc.), and because of that, the output is almost always just average.

There is more – generated code is often detached from the existing architecture. It’s just another "piece" that has to satisfy the requirements, but doesn’t necessarily match the system as a whole. In the long run, that comes with a big price, which is code quality. I don’t want to explain why code quality is important and what the consequences of bad code can be. Let’s just assume that code quality is important.

Additionally, on a platform like Salesforce, code quality matters even more because we share limits – SOQLs, DMLs, CPU Time, Heap Size, and so on – between different automations. You can write the best code in the entire world, but when another developer generates AI slop that consumes a lot of limits, your solution will be affected. Salesforce development requires great collaboration and synchronization between different teams. It’s not simple. Nobody likes tons of meetings and planning just to have an overview of the work of every Salesforce team in the organization.

But what if we could move that responsibility to frameworks? What if frameworks provided a way to ensure best practices and respect limits? A framework can provide rules that are shared between different teams, and if the teams follow them, your Salesforce development can go smoothly.

We have more problems today. I’ve encountered many situations where developers act as a "meat proxy" and throw a "slop grenade". If you haven’t heard about these terms, here are a few definitions:

Meat proxy – A person who forwards AI-generated text, code, or other output without reading, understanding, or validating it. The person acts only as a relay between the AI system and the intended recipient. ~ The San Francisco Field Guide (sfisms.org)

Slop grenade – Pasting a huge block of unreviewed AI output where one human sentence would do, the leaving. You look productive for ten minutes; someone else spends the afternoon defusing it. ~ The San Francisco Field Guide (sfisms.org)

So, if developers don’t understand the code and take shortcuts to ship faster (and it’s not their fault, as there is general pressure to be more productive – AI-first and other bullshit), ownership becomes a gray area. You don’t own the code, so you don’t own the solution. In this way, instead of becoming more efficient, we become more frustrated and burned out.

Assuming that nothing changes in the future and we vibe-code more and more, we need some kind of guardians – guardians of quality!

But wait, don’t we have them already?

Each provider (Codex, Claude Code, Copilot, etc.) gives us a set of tools that can help generate better code: agents, skills, rules, master prompts, and probably more that I’m not aware of. These are great tools, and we should definitely use them, but even with the best skills and rules, not all problems are solved.

Framework-Driven Development introduces a new level that can be put on top of existing tools and enforce code quality. Let’s dive deeper into that concept and see some examples.

What is Framework-Driven Development?

Framework-Driven Development is a concept where development is driven by frameworks (you don’t say, Sherlock).

What does that mean?

I came up with a few rules that FDD has to follow:

  1. Idiot-proof (AI-proof)
  2. Optimized for code and limits
  3. Quality by design

Framework-Driven Development

Idiot-proof (AI-proof) – Frameworks introduce a set of rules, design principles, and laws that cannot be skipped either by a developer or an AI Agent. Each lib has a kind of contract (usually interfaces and/or public methods) that can be used to create new code. There is no way around it: you/the AI Agent use only the methods exposed by the contract (interface). The contract should be well designed and prevent logical mistakes. It should somehow guide the developer and AI Agent and automatically provide feedback about problems.

For instance, DML Lib:

public interface Committable {
    Committable toInsert(...);
    // ...
    Committable toUpdate(...);
    // ...
    Committable toUpsert(...);
    // ...
    Committable toDelete(...);
    // ...
    Committable toHardDelete(...);
    // ...
    Committable toUndelete(...);
    // ...
    Committable toMerge(...);
    // ...
    Committable toPublish(...);
    // ...
    // Field Level Security
    Committable userMode();
    Committable systemMode();
    // Sharing Mode
    Committable withSharing();
    Committable withoutSharing();
    // Save
    Result commitWork();
    Result commitTransaction(); // make a savepoint and rollback on exception
}

DML Lib has a lot of internal classes and methods, but everything is hidden behind the public interface mentioned above.

Optimized for code and limits – Additionally, the libs handle the whole code optimization process and reduce the number of SOQLs, DMLs, and CPU Time to avoid hitting the limits. The developer/AI Agent doesn’t have to spend too much time on it because it’s the lib’s responsibility.

Quality by design – The framework provides rules that ensure code quality and follow the best design principles. There is no way (or at least it is not easy) to write slop code.

Now that we have the big-picture overview, let’s go into the details.

Idiot-proof (AI-proof)

Have you heard about poka-yoke, a Japanese technique for preventing errors before they happen? Or maybe an idiot-proof process? Or defensive design?

These terms refer to strategies where human errors (or, in our case, AI Agent errors) are minimized or eliminated through the proper design of the process.

Framework-Driven Development assumes that frameworks are designed in a way that prevents development mistakes. In Apex Fluently, we have a few ways to do it.

Limited Interfaces

Apex Fluently encapsulates (hides) the internal implementation behind limited interfaces. A developer/AI Agent cannot call every internal method, but only those that are allowed by the libs. In this way, we ensure that the code is written in the best possible way while maintaining code quality.

A good example here is SOQL Lib and its cache module:

// WHERE
Cacheable whereEqual(SObjectField field, Object value);
Cacheable whereEqual(String field, Object value);
// ...
// RESULT
Id toId();
Id toIdOf(SObjectField field);
Boolean doExist();
SObject toObject();
Object toValueOf(SObjectField fieldToExtract);

SOQL Lib blocks the creation of complex and nested conditions, and you cannot call toList() for the reasons described here. The toList() method could be very useful, but it can also lie about the results – that’s the nature of caching. To prevent mistakes and misleading results, the lib blocks it.

Each interface has been carefully thought out so that the code always follows best practices. For instance, in Trigger Lib, each context (before insert, before update, after insert, after update, etc.) has a different set of interfaces that allow only the actions that make sense for that context.

As we can read in the documentation:

Before triggers are used to update or validate record values before they’re saved to the database. ~ Salesforce

And Trigger Lib follows this by providing only Populator and Validator interfaces for that specific context:

public class BeforeInsert {
    public interface Populator extends Handler {
        Boolean populateOnBeforeInsertWhen(TriggerTypes.InsertRecord record);
        void populateOnBeforeInsert(TriggerTypes.InsertRecord record);
    }

    public interface Validator extends Handler {
        Boolean addErrorOnBeforeInsertWhen(TriggerTypes.InsertRecord record);
        void addErrorOnBeforeInsert(TriggerTypes.RejectableInsertRecord record);
    }
    // ...
}

Additionally, there is no way to perform DMLs in the before context – the lib blocks it. So even a developer who doesn’t know the best practices for triggers in that context won’t be able to mess it up.

Interfaces are a perfect way to provide feedback at compile time. When you try to deploy code using Salesforce CLI, you won’t be able to deploy it if your code does not follow the lib’s best practices. However, that’s only one way of preventing errors. The next one is internal validation.

Internal Validation

Framework-Driven Development assumes that a developer/AI Agent can be guided through thrown exceptions. When code is written incorrectly (and there is no way to recognize it at compile time), the libs throw exceptions. These exceptions can be seen in unit tests or during end-to-end testing.

Once again, let’s take a look at SOQL Lib and its cache module. The cache module requires at least one condition. If there is no condition, the following exception is thrown:

"A condition is missing. Please provide a filter to retrieve the cached record. You can ignore this validation by calling allowQueryWithoutConditions()’"

This provides a clear message and an alternative (allowQueryWithoutConditions()).

Another validation checks whether a cached query has a unique filter. If not:

"A cached query must have at least one condition based on Id, Name, DeveloperName, or a unique field. You can ignore this validation by calling allowFilteringByNonUniqueFields()".

As you can see, we have a lot of default validations that can be easily overridden. The goal here is that the developer/AI Agent has to explicitly skip the defaults and, in this way, take responsibility if something breaks. The libs themselves block mistakes.

Documentation

Besides interfaces and validation, Apex Fluently provides complete documentation for each lib, as well as skills in the libs’ repositories that can be easily read by AI Agents. There are tons of examples and principles that improve the output of generated code. Documentation is the last line of defensive design.

Optimized for code and limits

As I already mentioned, in the vibe-coding era, fewer and fewer developers know much about code architecture, best practices, and optimization. Sometimes, even when you know and understand them, you don’t have the time or energy to synchronize the work of different teams. The limits are shared between everyone!

Framework-Driven Development believes that at least part of that responsibility should be moved into the development process. A development process that relies on frameworks can be much more efficient and better optimized. The libs have internal optimizations that help avoid exceeding Salesforce limits.

Async Lib

The best example here would be Async Lib, which fully orchestrates async jobs (queueable, schedulable, batchable) in Apex.

One of the biggest issues, which is not that easy to solve when dealing with async processing, is "Too many queueable jobs". The issue can appear out of nowhere.

Example from Stack Exchange:

So if you update 201 records, you will have one Queueable kicked off with 200 records, and another kicked off with just one record. As soon as you execute a second job, you’re going to error out. ~ https://salesforce.stackexchange.com/questions/207441/error-too-many-queueable-jobs-added-to-the-queue-2

So everything can look good, but one day you have 201 records in one transaction and suddenly face "Too many queueable jobs". There are more scenarios where you can encounter it. Usually, it requires proper synchronization of processes or workarounds like:

if (System.isBatch() || System.isFuture() || System.isQueueable() || System.isScheduled()) {
   return;
}

// logic here..

Async Lib takes that responsibility away from developers/AI Agents and handles it automatically. You don’t need to check existing code, and you don’t need to be afraid of writing code, because responsibility for the proper synchronization of async jobs has been moved to the lib itself.

DML Lib

Another great example is DML Lib, a lib that minimizes the number of DMLs by creating a dependency graph and resolving it using Kahn’s algorithm. You can see how accounts, contacts, opportunities, and leads registered in different parts of the system are grouped based on type and operation and inserted/updated/upserted/deleted based on dependencies:

It doesn’t matter how a developer or AI Agent registers records for DML operations; the lib itself will optimize them. They don’t need to be aware of other processes/classes that may add records to the UOW instance. In this way, we mitigate human/AI errors by moving responsibility to the lib.

SOQL Lib

How do we reduce the number of SOQL queries?

You can use SOQL Lib with its cache module. The lib covers the whole caching process. The only thing developers have to do is decide which objects are worth caching.

You can easily configure whether data should be stored in an Apex static variable (.cacheInApexTransaction()), org cache (.cacheInOrgCache()), or session cache (.cacheInSessionCache()). Additionally, you can easily configure how long data should stay in the cache with the maxHoursWithoutRefresh(hours) method.

public inherited sharing class SOQL_CachedProfile extends SOQLCache implements SOQLCache.Selector {
    public static SOQL_CachedProfile query() {
        return new SOQL_CachedProfile();
    }

    private SOQL_CachedProfile() {
        super(Profile.SObjectType);
        // default settings
        with(Profile.Id, Profile.Name, Profile.UserType)
            .cacheInOrgCache()
            .maxHoursWithoutRefresh(36);
    }

    public SOQL_CachedProfile byName(String profileName) {
        whereEqual(Profile.Name, profileName);
        return this;
    }
}

You don’t need to orchestrate how records are saved to and read from the cache. If you don’t override it, you don’t need to think about the architecture – the lib already does it.

Trigger Lib

Trigger Lib allows you to reduce the number of parent queries, related queries, and DMLs.

The only thing you need to do is implement the ParentQuery interface.

public with sharing class ContactPopulator implements BeforeInsert.Populator, BeforeInsert.ParentQuery {
    public Map<SObjectField, TriggerTypes.ParentFields> queryParentsOnBeforeInsert() {
        return new Map<SObjectField, TriggerTypes.ParentFields>{ 
            Contact.AccountId => TriggerTypes.ParentFields.with(Account.Name, moreFieldsHere) 
        };
    }

    public Boolean populateOnBeforeInsertWhen(TriggerTypes.InsertRecord record) {
        return record.getNewParent(Contact.AccountId) != null;
    }

    public void populateOnBeforeInsert(TriggerTypes.InsertRecord record) {
        Account parentAccount = Account) record.getNewParent(Contact.AccountId);
        record.put(Contact.Description, parentAccount.Name);
    }
}

What’s more interesting is that you can have 150 different handlers that are not aware of each other. If each handler made one query, you would hit System.LimitException: Too many SOQL queries: 101. However, Trigger Lib takes the ParentQuery from all active handlers and performs the minimum number of bulk queries, and only when needed. So instead of 150 queries, you can end up with just a few.

A similar rule applies to DMLs. When it comes to DMLs, the lib gives you a shared Unit of Work. Each handler registers records for insert/update/upsert/delete/publish, DML Lib builds a dependency graph and sorts records by type and operation. It also ensures that parents are created before child records, and at the end, everything is committed at once.

public class ContactWriter implements AfterInsert.Writer {
    public Boolean writeOnAfterInsertWhen(TriggerTypes.InsertRecord record) {
        return record.isNotNull(Contact.AccountId);
    }

    public void writeOnAfterInsert(TriggerTypes.InsertRecord record, TriggerTypes.UnitOfWork unitOfWork) {
        Contact currentRecord = (Contact) record.getNewSObject();
        unitOfWork.toInsert(
            new Task(WhatId = currentRecord.AccountId, Subject = 'Review contact')
        );
    }
}

Of course, for edge cases, you can implement and inject your own UOW for a specific handler, but you need to do it explicitly. All deviations from the standard provided by Apex Fluently have to be explicit, so in such cases, responsibility is moved from the framework to the developer who made that decision.

public with sharing class ContactWriter implements AfterInsert.Writer, AfterInsert.OwnUnitOfWork {
    public DML.Committable ownUnitOfWorkOnAfterInsert() {
        return new DML().userMode().identifier('ContactWriter');
    }

    public Boolean writeOnAfterInsertWhen(TriggerTypes.InsertRecord record) {
        return record.isNotNull(Contact.AccountId);
    }

    public void writeOnAfterInsert(TriggerTypes.InsertRecord record, TriggerTypes.UnitOfWork unitOfWork) {
        unitOfWork.toInsert(new Task(WhatId = ((Contact) record.getNewSObject()).AccountId, Subject = 'Review contact'));
    }
}

Quality by design

Framework-Driven Development assumes that the libs ensure not only that mistakes are prevented, as described above, but also that using the libs naturally leads to good code quality and the use of best design principles.

As I already described using Trigger Lib as an example, each context allows only what is meant for that context. In the before context, we can populate trigger record fields and perform validation. In the after context, we can perform DMLs, callouts, enqueue async jobs, etc. In this way, we can be sure that we have the proper architecture (as recommended by Salesforce).

Additionally, Trigger Lib forces classes to stay small. It’s almost impossible to create a class that has more than 100 lines of code. Of course, we will have a lot of small classes because of this, but each class will have a single responsibility, will be easy to change, and in general will follow the S.O.L.I.D. principles.

You’ve probably heard of the unit of work or the selector layer. Apex Fluently gives you those as well. Unlike the fflib implementation, where selectors can become huge and messy, Apex Fluently selectors are small and easy to extend, following the builder pattern.

For Whom?

Framework-Driven Development can be a good choice not only for developers, but surprisingly also for administrators in some areas. Personally, I don’t like Record-Triggered Flows. They can do more harm than good, but Flows are a tool mostly used by Salesforce Administrators.

However, when part of the logic is written in Apex Triggers and the rest is implemented in Flows, you can easily end up hitting Salesforce limits. These days, admins can generate trigger code instead of using Flows, but because they may not fully understand the generated code, frameworks can guide them.

That approach, of course, would require not only Trigger Lib, but also agent skills, code scanners, and a proper code review process.

Conclusion

AI agents need strong rules and guidelines to generate high-quality code. Developers often rely on AI skills and rules, but that’s not enough. Even with the best skills, agents can still do some stupid things.

A solution can be well-designed frameworks like Apex Fluently, where the library design prevents errors and ensures code quality – there is simply no way to write bad code.

Of course, there are many libraries, such as SOQL Lib, DML Lib, Async Lib, and Trigger Lib. You don’t have to use all of them, but the more libraries you use across different areas, the better the output you will get.

In an ideal world, you should have three things that can improve the AI output: Libraries, Context, and Testing.

Libraries for code quality

A unified way to work with:

  • DMLs
  • SOQLs
  • Triggers
  • Constants
  • Callouts
  • Async Jobs
  • Unit Tests (Mocking, HTTP Mocks)

Context

Here, you can use skills, rules, and agents to fill the gaps. An additional documentation repository can also be very useful when it documents all project standards, processes, endpoints, approaches to creating permission sets, personas, etc., exactly as described in Context-Driven Delivery.

Testing

Here, I would aim for comprehensive testing, including:

  • Fast, mocked unit tests
  • End-to-end tests
  • API callout tests, e.g. Bruno
  • Playwright tests
  • LWC Jest tests

In some cases, Test-Driven Development (TDD) can be used before generating the actual solution.

Piotr Gajek
Piotr Gajek
Senior Salesforce Developer
Technical Architect and Full-stack Salesforce Developer. He started his adventure with Salesforce in 2017. Clean code lover and thoughtful solutions enthusiast.

You might also like

Abstract, Virtual, Interface in Apex
August 14, 2022

Abstract, Virtual, Interface in Apex

Find the differences between Abstract, Virtual, and Interface implementation in Apex code. Understand the purpose and make your code better.

Piotr Gajek
Piotr Gajek

Senior Salesforce Developer

SOQL Lib

SOQL Lib

The SOQL Lib provides functional constructs for SOQL queries in Apex. Use the SOQL Lib as a Selector Layer on your project.

Piotr Gajek
Piotr Gajek

Senior Salesforce Developer