Let's be honest. Anyone can learn the syntax of Apex in a few weeks. The Trailhead modules are great, the documentation is there, and you can get a trigger to fire without much trouble. But writing Apex that works is a world away from writing good Apex. I’m talking about code that is clean, efficient, and won't fall over the second your user base doubles. Code that the next developer who looks at it won't curse your name.
I've been in the Salesforce ecosystem for a long time, and I've seen it all. The brilliant, elegant solutions and the tangled messes that cost companies fortunes to untangle. The biggest mistake I see people make is thinking the job is done when the feature is "working." That's not the end. It's the beginning. Your job as a professional developer is to build solutions that last.
So, let's talk about what that actually means. Forget the dry, academic definitions for a minute. This is the hard-won advice I wish someone had given me when I was starting out. This is about moving from being a coder to being an engineer. It’s about the real-world practices that separate the amateurs from the pros in Apex Programming.
It's Not Just About Making It Work; It's About Making It Last
Your first goal is always to solve the business problem. Of course it is. But your second, equally important goal, is to solve it in a way that doesn't create three new problems down the road. This is the core of professional development. You're not just writing code for the compiler; you're writing it for the human who has to maintain it in six months, a year, or five years. And guess what? That human might be you.
Think of your code as a part of a larger machine. Every piece you add should be well-crafted, clearly labeled, and easy to replace or upgrade. When you write messy, confusing code, you're throwing a wrench into that machine. It might work for a while, but eventually, it's going to cause a breakdown. And those breakdowns are always more expensive to fix than it would have been to build it right the first time.
Master Your Governor Limits (Before They Master You)
If you've written more than ten lines of Apex, you've heard of governor limits. But do you really understand why they exist? Salesforce is a multi-tenant environment. Think of it like living in a big apartment building. You can't have a massive, loud party every night because it would disturb your neighbors. Governor limits are the building rules. They ensure that your code—your "party"—doesn't consume all the shared resources (CPU time, memory, database connections) and ruin the experience for everyone else on the same server.
Ignoring these limits isn't an option. Your code will literally crash and burn. The key is to write code that is naturally efficient and respectful of these limits from the start.
Bulkification is Non-Negotiable
This is rule number one. It's the most common reason for hitting governor limits, and it's completely avoidable. Bulkification simply means writing your code to handle records in collections (like a List or a Map), not one at a time. Never, ever, ever put a SOQL query or a DML statement (insert, update, delete) inside a loop.
I can't stress this enough. If I had a dollar for every time I've seen a SOQL query inside a for loop, I'd be retired on a private island. It's the cardinal sin of Apex Programming.
Don't do this. Ever.
// This code is a time bomb.List<Account> accountsToUpdate = new List<Account>();for (Contact c : Trigger.new) { if (c.Department == 'Finance') { // SOQL query inside a loop! Bad! Account acc = [SELECT Id, Description FROM Account WHERE Id = :c.AccountId]; acc.Description = 'Contact from Finance department added.'; accountsToUpdate.add(acc); }}// DML statement inside a loop if you were updating one by one.// update accountsToUpdate; This code works perfectly fine if you insert one Contact. But what happens when a user uploads 200 Contacts using a data loading tool? Your code will try to run 200 separate SOQL queries. The 101st query will cause it to hit the governor limit (100 SOQL queries per transaction), and the whole operation will fail with an unhandled exception. The user gets an angry red error message, and you get a frantic email.
Do this instead.
// This is bulk-safe and efficient.Set<Id> accountIds = new Set<Id>();for (Contact c : Trigger.new) { if (c.Department == 'Finance') { accountIds.add(c.AccountId); }}if (!accountIds.isEmpty()) { // One single query to get all necessary accounts. Map<Id, Account> accountsToUpdate = new Map<Id, Account>([ SELECT Id, Description FROM Account WHERE Id IN :accountIds ]); for (Id accId : accountsToUpdate.keySet()) { accountsToUpdate.get(accId).Description = 'Contact from Finance department added.'; } // One single DML statement to update all records. update accountsToUpdate.values();}See the difference? We loop once to collect the IDs we need. Then we perform one single query to get all the data. Then we process that data in memory. Finally, we perform one single DML statement. This code will handle 1 record or 200 records with the exact same number of queries and DML statements. This is the essence of bulkification. This becomes even more critical as you start working with massive datasets, perhaps from Data Cloud, where efficiency isn't just a best practice; it's a requirement.
SOQL and DML are Expensive. Treat Them That Way.
Think of every query and every DML statement as spending money from a very limited budget. You want to be as frugal as possible. Beyond just keeping them out of loops, you should always be asking: "Can I get this done with fewer queries?"
This is where Maps become your best friend. A Map lets you hold data in a key-value structure, allowing you to retrieve a specific record by its ID without having to query for it again. Using Maps effectively is a hallmark of an experienced Apex developer.
Writing Clean, Readable Code is a Superpower
Code is read far more often than it is written. Let that sink in. Your goal should be to write code that is so clear, it almost doesn't need comments. It should be self-documenting. This isn't about being clever; it's about being clear.
Naming Conventions Aren't Just for Pedants
Clear, consistent naming is the cheapest and most effective way to improve code quality. Don't use single-letter variable names like `a` or `x`. Don't name a list `list1`. Be descriptive.
- Variable: `Set<Id> contactIdsToProcess` is better than `cIds`.
- Method: `calculateShippingCosts()` is better than `calc()`.
- Class: `AccountTriggerHandler` is better than `AcctHelper`.
It seems trivial, but when you come back to your code six months later, you'll be grateful you took the extra five seconds to name things properly.
Your Future Self Will Thank You for Good Comments
There's a debate about comments in the programming world. Some say code should be so clear it needs no comments. I believe the best approach is to comment on the *why*, not the *what*. The code itself shows you *what* it's doing. A good comment tells you *why* it's doing it that way.
Bad Comment (Useless):
// Loop through contactsfor (Contact c : contacts) { ... }Good Comment (Useful):
// We must process these contacts without sharing to update a related// custom object that the running user may not have access to. This is// required by the business process for compliance logging.without sharing { for (Contact c : contacts) { ... }}The second comment explains the business context and the reason for a specific design choice (`without sharing`). That's incredibly valuable information.
The Single Responsibility Principle isn't Just for Architects
This sounds fancy, but the idea is simple: a class or a method should have only one reason to change. In other words, it should do one thing and do it well. Don't write a single, 2000-line method that queries data, transforms it, integrates with an external system, and then updates records. That's a monster.
Break it down. Create a service class. Have one method for getting the data. Another for the transformation logic. A third for the callout. This makes your code easier to read, easier to test, and easier to debug. If the callout logic needs to change, you only have to touch one small method, not a giant one. This modularity is also key when you need to decide if a piece of logic should be Apex or something else. Sometimes, the best Apex code is no code at all. If a complex approval process can be handled efficiently in Flow Builder, that's often the right call. Your service classes can then be called from either Apex or Flow, giving you flexibility.
A Solid Trigger Framework is Your Best Friend
I remember a project where we inherited an org with ten active triggers on the Account object. Ten. One was for a validation rule, one updated related contacts, one made a callout, another handled a rollup summary… it was a nightmare. You could never be sure what order they would run in, and debugging a simple field update was a full-day investigation. It was chaos.
This is why you need a trigger framework. It's not optional for any serious Salesforce development.
Why You Need a Framework
A trigger framework provides structure and control. It allows you to:
- Control the Order of Execution: You decide the exact order in which your logic runs (e.g., validations first, then field updates, then callouts).
- Promote Reusability: You can call the same piece of logic from multiple places without duplicating code.
- Easily Bypass Logic: Need to run a data load without your triggers firing? A good framework has a switch to disable the logic easily.
One Trigger Per Object. Period.
This is my firmest belief in Salesforce architecture. You should have exactly one trigger for each object (e.g., `AccountTrigger`, `ContactTrigger`). That's it. This one trigger is nothing more than a dispatcher. Its only job is to look at the context of the operation (is it an insert? an update? before or after the save?) and delegate the actual work to a separate handler class.
// AccountTrigger.triggertrigger AccountTrigger on Account (before insert, after insert, before update, after update, ...) { // The only thing the trigger does is call the handler. AccountTriggerHandler.handle();}The `AccountTriggerHandler` class then contains all the logic, neatly organized into methods. This pattern solves the order of execution problem and makes your automation predictable and maintainable. It's especially crucial when dealing with complex processes, like a Salesforce Integration that might update records and kick off a chain of events.
Don't Forget About Security and Data Integrity
Writing functional code is one thing. Writing secure code is another. In the Salesforce world, this often comes down to respecting user permissions and handling data correctly.
'With Sharing' is Your Default
Apex classes, by default, run in system mode, meaning they ignore the user's permissions and can see and modify all data. This is powerful but dangerous. To enforce the user's permissions, you must declare your class with the `with sharing` keyword. I believe your default should always be `with sharing`. Only use `without sharing` when you have a specific, documented reason to escalate a user's privileges for a certain operation. This protects your data and ensures users only see what they're supposed to see.
Exception Handling is Not Optional
Things will go wrong. A query will return no rows when you expected one. A callout will time out. A user will enter bad data that your validation rule missed. Your code must be prepared for this. Use `try-catch` blocks to handle potential errors gracefully. Don't just swallow the exception with an empty `catch` block. Log the error to a custom object, show the user a friendly message, and prevent the entire transaction from failing messily. Good exception handling is the difference between a brittle application and a resilient one. You might even build a service class that, in the future, needs to call out to Salesforce Einstein AI for predictions; that call could fail, and your code needs to handle it.
Testing Isn't a Chore; It's Your Safety Net
Finally, let's talk about testing. Too many developers see testing as a chore they have to complete to meet the 75% code coverage requirement for deployment. This is the wrong mindset. Tests are your safety net. They are an investment in the future stability of your application. They prove that your code does what you think it does, and they protect you from future changes breaking your existing logic.
Test for Logic, Not Just for Coverage
Don't just write a test that runs your code. Write a test that verifies the outcome. Use `System.assertEquals()` and `System.assertNotEquals()` to prove that your logic worked correctly. Test the "happy path," but also test the negative scenarios. What happens if you pass in a null value? What happens if a required field is missing? What happens when you process a list of 200 records? A good test suite covers all these cases.
Use @TestSetup for Efficiency
Creating test data can be slow. If you have multiple test methods in a class that all need the same set of records (e.g., an Account and some Contacts), use a method annotated with `@TestSetup`. This method runs once and creates the common test data. All the test methods in the class then get access to that data in a fresh state, without having to recreate it every single time. This can dramatically speed up your test execution time.
Conclusion: The Journey of a Craftsman
Becoming a great Apex developer isn't about memorizing syntax. It's about adopting a mindset of professionalism, discipline, and craftsmanship. It's about thinking in terms of bulkification, writing clean and readable code, using solid architectural patterns like a trigger framework, prioritizing security, and embracing testing as a critical part of the development process.
These practices aren't just theories. They are the foundation of every scalable, maintainable, and successful Salesforce implementation I've ever seen. Start applying them today. Your future self, and every developer who works on your code after you, will thank you for it. The journey from coder to craftsman is a continuous one, but it's a journey worth taking.
