Extend the Platform · For Developers & Admins
Custom Class Code Guide
From Your First Variable to a Complete Class
Everything you need to write Custom Classes: variables (and when to use def), text, numbers, lists, maps, decisions, loops, methods, reading and saving records, errors and limits; then how a SmartLite Component calls your class, what goes in and comes back, how to test a method and what saving checks. It ends with two complete classes, one on Accounts and Contacts and one on a custom object. Every code sample was checked in the real sandbox.
Language Basics #
1. Where the code goes #
A Custom Class is written in Groovy, a language very close to Java but with less typing. Everything lives inside one class; each piece of work is a method inside it.
- Setup → Customization → Classes → New Class: for classes run by a button, a page, a component or a Workflow Rule.
- Setup → Customization → Developer Console → Saved Scripts → New Script: the same classes, plus Batch Mode and Run Daily for jobs over many records.
- Paste the code into Source Code and click Save. Saving checks the code: a mistake is shown straight away, before anything runs.
class HelloWorld {
// The Run button calls the method named execute()
def execute() {
Platform.log('Hello from my first class')
return 'done'
}
}The class name (HelloWorld) is the class's API name: there's no separate name field. Click Run on its row to see the log line. Names can't be changed later: to rename, delete and create it again.
Where classes run
Custom code is switched off in production for now, while its sandbox is strengthened: saving or running a class there answers temporarily unavailable. Everything on this page works in your development and test environments.
Comments
// text is a comment to the end of the line, and /* … */ spans several lines. Comments are ignored when the code runs.
2. Declaring a variable: def or a type #
Before you use a new variable inside a method, you declare it once. You have two ways to do that, and they work the same in almost every case:
def name = 'Priya' // def: "a variable", the type is worked out when it runs
String city = 'Pune' // or say exactly what it holds
int count = 0
BigDecimal amount = 1250.50
boolean active = true
List tags = ['vip', 'partner']
Map lead = [ first_name: 'Priya', status: 'New' ]
count = count + 1 // after declaring, just assign: no def or type again
return name + ' / ' + city + ' / ' + count| Write this | When to use it |
|---|---|
def x = … | When you don't need to say the type, or the value can be different kinds of thing (a record's fields are text, numbers or true/false). Also required for a variable holding one of your own classes, see below. |
String, int, long, BigDecimal, boolean | Text, whole numbers, decimals (money), true/false. Good for readability: anyone can see what the variable holds. |
List, Map | A list of values, or named values (a record is a Map of field → value). |
Date | A moment in time: Date now = new Date(). |
Can I leave def out completely? #
Yes, by writing a type instead. def is just the "no particular type" choice; every example on these pages can be written with String, int, List, Map and so on (the complete example in tab 3 is written that way). What you can't do is write neither. This one even saves, but fails as soon as it runs, with No such property: total:
class NoDeclaration {
def execute() {
total = 5 // not declared: fails when it runs
return total
}
}There are two places you must use def: a variable that holds an object of a class you wrote yourself (a model class), shown here, and reading a caught error (section 17).
class Board {
def rows() {
CaseRow row = new CaseRow() // typed as your own class: save fails
row.setSubject('Printer down')
return [ row ]
}
}
class CaseRow {
private String subject
void setSubject(String v) { subject = v }
String getSubject() { return subject }
}class Board {
def rows() {
def row = new CaseRow() // def: saves and runs
row.setSubject('Printer down')
return [ row ]
}
}
class CaseRow {
private String subject
void setSubject(String v) { subject = v }
String getSubject() { return subject }
}Why the difference?
The safety sandbox checks every method call against a list of types it allows: text, numbers, lists, maps, dates and Platform. When you write a type, the sandbox checks it at Save, and your own classes are not on that list. With def the type is only known when the code runs, so the check doesn't block it. Built-in types are on the list, which is why String name or List rows are fine.
3. Text (String) #
String first = 'Priya'
String last = "Nair" // single or double quotes
String full = first + ' ' + last // join with +
String greeting = "Hello ${first}, welcome back" // "${...}" works inside double quotes only
String email = ' Priya.Nair@Example.COM '.trim().toLowerCase()
boolean isGmail = email.endsWith('@gmail.com')
String domain = email.substring(email.indexOf('@') + 1) // example.com
List parts = 'a,b,c'.split(',') as List
String short5 = 'SmartLite'.take(5) // Smart
return [ full, greeting, email, isGmail, domain, parts, short5 ]| Method | Gives |
|---|---|
s.trim() | without spaces at both ends |
s.toLowerCase() / toUpperCase() | lower / upper case |
s.contains('x'), startsWith, endsWith | true or false |
s.replace('a', 'b') | every a replaced by b |
s.split(',') | the pieces between commas |
s.take(n), s.substring(from, to) | part of the text |
s.size() | number of characters |
s ==~ /regex/ | true when the whole text matches the pattern |
4. Numbers #
int quantity = 3
BigDecimal price = 19.99 // decimals are exact: use them for money
BigDecimal total = price * quantity // 59.97
BigDecimal perItem = 10 / 4 // 2.5: dividing gives a decimal, not 2
BigDecimal rounded = (100 / 3).setScale(2, 4) // 33.33 (2 decimals, rounding half up)
BigDecimal fromText = '1250.50'.toBigDecimal() // record values often arrive as text
int whole = '42'.toInteger()
return [ total, perItem, rounded, fromText + whole ]Record values are text
Fields read from a record (for example an Opportunity's amount) can arrive as text such as '1250.5'. Convert before you do arithmetic: opp.amount ? opp.amount.toString().toBigDecimal() : 0.
5. True, false, nothing (null) and two handy operators #
Map contact = [ first_name: 'Sam', email: null, phone: '' ]
// In an if, these all count as false: null, '', 0, an empty list, an empty map
if (!contact.email) Platform.log('no email')
if (!contact.phone) Platform.log('no phone')
// ?. stops at null instead of failing: null?.trim() is just null
String cleanEmail = contact.email?.trim()
// ?: gives a fallback when the left side is null or empty
String phone = contact.phone ?: 'not given'
return [ cleanEmail, phone ]6. Lists #
List stages = ['Prospecting', 'Qualification']
stages.add('Negotiation') // or: stages << 'Negotiation'
String first = stages[0] // 'Prospecting'
String last = stages[-1] // 'Negotiation'
int howMany = stages.size() // 3
boolean has = stages.contains('Qualification')
boolean isIn = 'Closed Won' in stages // false
List sorted = stages.sort(false) // a sorted copy
return [ first, last, howMany, has, isIn, sorted ]7. Maps (named values) — every record is one #
Map account = [ name: 'Acme', industry: 'Retail' ]
account.city = 'Austin' // add or change a value
account['rating'] = 'Hot' // same thing, useful when the key is in a variable
String n = account.name // 'Acme'
boolean known = account.containsKey('industry')
Map empty = [:] // an empty map
account.each { key, value -> Platform.log(key + ' = ' + value) }
return [ n, known, account.size(), empty.size() ]8. Making decisions #
BigDecimal amount = 72000
String size
if (amount >= 100000) {
size = 'Large'
} else if (amount >= 25000) {
size = 'Medium'
} else {
size = 'Small'
}
String label = amount > 50000 ? 'Big deal' : 'Normal' // short if/else
String priority = 'High'
int hours
switch (priority) {
case 'Critical': hours = 4; break
case 'High': hours = 8; break
case ['Medium', 'Low']: hours = 48; break
default: hours = 72
}
return [ size, label, hours ]9. Loops #
List names = ['Ana', 'Ben', 'Cy']
for (String n : names) { // classic loop: break and continue work here
if (n == 'Ben') continue
Platform.log('hi ' + n)
}
names.each { n -> Platform.log(n) } // same idea, shorter
names.eachWithIndex { n, i -> Platform.log(i + ': ' + n) }
int tries = 0
while (tries < 3) { tries++ }
for (int i = 1; i <= 3; i++) { Platform.log('round ' + i) }
return triesSkipping inside each
Inside each { … }, return skips to the next item (it acts like continue). To stop early, use a for loop and break.
10. Working with lists of records: closures #
A closure is the bit in { … }: a small piece of code you hand to a method. it is the current item, or name it yourself: { lead -> … }. These methods do most of the work in real classes:
List opps = [
[ name: 'A', stage: 'Closed Won', amount: '5000' ],
[ name: 'B', stage: 'Prospecting', amount: '1200' ],
[ name: 'C', stage: 'Closed Won', amount: '800' ]
]
List won = opps.findAll { it.stage == 'Closed Won' } // keep matching items
Map firstWon = opps.find { it.stage == 'Closed Won' } // first match, or null
List names = opps.collect { it.name } // ['A', 'B', 'C']
BigDecimal sum = won.sum { it.amount.toBigDecimal() } // 5800
int wonCount = opps.count { it.stage == 'Closed Won' } // 2
boolean anyBig = opps.any { it.amount.toBigDecimal() > 4000 } // true
boolean allWon = opps.every { it.stage == 'Closed Won' } // false
Map byStage = opps.groupBy { it.stage } // stage -> list
List biggest = opps.sort(false) { a, b -> b.amount.toBigDecimal() <=> a.amount.toBigDecimal() }
String joined = names.join(', ') // 'A, B, C'
Map idToName = opps.collectEntries { [ (it.name): it.stage ] }
return [ won.size(), firstWon.name, sum, wonCount, anyBig, allWon, byStage.keySet(), biggest[0].name, joined, idToName ]| Method | Returns |
|---|---|
findAll { … } | every item where the closure is true |
find { … } | the first such item, or null |
collect { … } | a new list built from each item |
sum { … } | the total (null for an empty list, so add ?: 0) |
count, any, every | how many / at least one / all of them |
groupBy { … } | a map from each value to the items that have it |
sort { a, b -> … } | sorted; <=> compares two values |
take(n), unique(), join(', '), max(), min() | first n / without repeats / as one text / largest / smallest |
11. Dates #
Date fields come back as text like 2026-10-23, and that text sorts in date order, so you can compare dates as text. To get today's date, add this helper to your class:
class DateHelpers {
def execute() {
String today = day(0) // e.g. 2026-10-23
String inAWeek = day(7)
String lastYear = day(-365)
Platform.log(today + ' ' + inAWeek + ' ' + lastYear)
return '2026-01-15' < today // true: compares as dates
}
// Today's date as yyyy-MM-dd (UTC), plus or minus some days
private String day(int offset) {
return new Date(new Date().getTime() + offset * 86400000L).toInstant().toString().substring(0, 10)
}
}Why not LocalDate.now()?
The sandbox refuses calls made on a class name, such as LocalDate.now(), Math.round(x) or String.valueOf(x). Call methods on a value instead: new Date(), x.toString(), (x * 1.0).setScale(0, 4).
Methods, Classes & Records #
12. Methods #
A method is a named piece of work. It can take parameters and give back a return value. The last line's value is returned automatically, but writing return is clearer.
class PriceTools {
// Called from a SmartLite Component: import quote from '@class/PriceTools.quote'
// then await quote({ price: 100, qty: 3 })
Map quote(Map params) {
BigDecimal price = params.price.toString().toBigDecimal()
int qty = params.qty.toString().toInteger()
BigDecimal subtotal = price * qty
return [ subtotal: subtotal, tax: tax(subtotal), total: subtotal + tax(subtotal) ]
}
// private: a helper only this class uses
private BigDecimal tax(BigDecimal amount) {
return (amount * 0.18).setScale(2, 4)
}
// Run button
def execute() {
return quote([ price: 100, qty: 3 ])
}
}| Who calls it | Which method runs | What it receives |
|---|---|---|
| The Run button | execute() | nothing |
| A Workflow Rule's Run Custom Class action | execute() | nothing: it doesn't get the saved record |
| A batch job (Batch Mode) | execute(batch) | a list of records, one chunk at a time |
A SmartLite Component: import m from '@class/Name.method', then await m({…}) | the method you import | the object you pass, as a Map |
A classic Custom Page or Component (made before SmartLite Components): callClass('Name', 'method', {…}) | the method you name | the object you pass, as a Map |
Another class: Platform.callClass('Name', 'method', [ … ]) | the method you name | the map you pass |
def or a type for methods
def quote(params) and Map quote(Map params) are the same method. Types say what goes in and out; def is shorter. Use void for a method that returns nothing.
13. Fields: values the whole class shares #
class Discounts {
// Fields sit at the top of the class. Every method can use them.
Map ratesByTier = [ Gold: 15, Silver: 10, Bronze: 5 ]
int maxDiscount = 20
int forTier(Map params) {
int rate = ratesByTier[params.tier] ?: 0
return Math.min(rate + (params.extra ?: 0), maxDiscount)
}
}That example doesn't save, because of Math.min (a call on a class name, see section 11). The working version:
class Discounts {
Map ratesByTier = [ Gold: 15, Silver: 10, Bronze: 5 ]
int maxDiscount = 20
int forTier(Map params) {
int rate = (ratesByTier[params.tier] ?: 0) + (params.extra ?: 0)
return [ rate, maxDiscount ].min()
}
}Fields start fresh every run
Each run creates a new copy of the class, so a field can't remember anything between runs. To keep a value, save it on a record.
14. The Platform toolbox #
Platform is available in every method with nothing to set up. It is the only way a class reads or changes data, and it always works inside your own company's data, with the permissions of the person running it.
| Call | What it does | Gives back |
|---|---|---|
Platform.query('account') | Up to 500 records of an object | List of maps, each with hex_id |
Platform.query('contact', 'account_id', accountId) | Records where one field equals a value (up to 500) | List of maps |
Platform.getRecord('account', id) | One record by its ID | Map with id, or null |
Platform.newRecord() | An empty map to fill in before insert: def r = Platform.newRecord(); r.name = 'Acme' | Map |
Platform.insert('account', [ name: 'Acme' ]) | Creates a record | the new record's ID |
Platform.insertAll('contact', [ [ … ], [ … ] ]) | Creates several | list of IDs |
Platform.update(id, [ status: 'Working' ]) | Changes only the fields you list | nothing |
Platform.delete(id) | Moves the record to the Recycle Bin | nothing |
Platform.log('text') | Writes a line to the run output | nothing |
Platform.callClass('Name', 'method', [ … ]) | Runs a method of another saved class | whatever that method returns |
Platform.httpGet('credential', '/path') (also httpPost, httpPut, httpDelete) | Calls an outside service through a Named Credential | [ status, body, headers ] |
Platform and CRM
Inside a class the toolbox is called Platform. In the Developer Console's Execute Script tab, and in scripts saved before classes existed, the same toolbox is called CRM: CRM.query(…), CRM.log(…). Platform doesn't exist there.
15. Reading records #
// Create some data to read (a real class would read what is already there)
String acmeId = Platform.insert('account', [ name: 'Acme Guide Co', industry: 'Retail' ])
Platform.insert('contact', [ first_name: 'Ana', last_name: 'Diaz', email: 'ana@acme.com', account_id: acmeId ])
Map acme = Platform.getRecord('account', acmeId)
String industry = acme.industry // field API names, exactly as in Object Manager
List contacts = Platform.query('contact', 'account_id', acmeId)
for (Map c : contacts) {
Platform.log(c.first_name + ' ' + c.last_name + ' <' + c.email + '> id ' + c.hex_id)
}
return [ industry, contacts.size() ]| Good to know | |
|---|---|
| Field names | Use the field's API name from Object Manager → object → Fields: first_name, not First Name. Custom fields end in __c. |
| The record's ID | hex_id on rows from query; id on a record from getRecord. |
| Lookups | A lookup field (e.g. a Contact's account_id) holds the other record's ID. Pass it to getRecord to read that record. |
| Filtering | query(object, field, value) matches one field exactly. For anything else, query and then findAll { … }. |
| 500 records | A query returns at most 500. For more, use Batch Mode. |
| Visibility | A class run from a page or component sees only the records that user can see. A record they can't see comes back as null. |
16. Creating, changing and deleting records #
String id = Platform.insert('account', [ name: 'Globex Guide', type: 'Prospect' ])
Platform.update(id, [ type: 'Customer', rating: 'Hot' ]) // other fields are left alone
Map saved = Platform.getRecord('account', id)
Platform.delete(id) // goes to the Recycle Bin
return [ saved.type, saved.rating ]A class saves records exactly like a person does: required fields, validation rules, duplicate rules and Workflow Rules all still apply. If a save is refused, the class stops with that message (see the next section to handle it).
17. Errors #
class SafeCreate {
Map create(Map params) {
// Stop with a clear message when the input is wrong
if (!params.name) throw new IllegalArgumentException('name is required')
try {
String id = Platform.insert('account', [ name: params.name, phone: params.phone ])
return [ ok: true, id: id ]
} catch (Exception e) {
// e.g. a validation rule refused it: report it instead of failing
def problem = e // def: see the note below
Platform.log('insert failed: ' + problem.getMessage())
return [ ok: false, message: problem.getMessage() ]
}
}
}Reading the error needs def
e.getMessage() directly on catch (Exception e) is refused at Save, because the sandbox doesn't allow calls on the Exception type. Copy it into a def variable first, as above. This is the second place where def is required.
Throw or return?
When a class throws, the component's await fails, and the message it gets starts with the error's type and the line in your class, e.g. IllegalArgumentException at line 4: name is required. That's fine for the unexpected. For problems the user should read and fix, return them: [ ok: false, message: 'name is required' ], as above, and show message. Section 24 shows both from the component's side.
18. Output: Platform.log #
println does nothing inside a class. Use Platform.log(anything): the lines appear in the Run output on the Classes page and in the Developer Console, and every insert/update/delete is listed there too.
19. Limits for each run #
| Limit | Value | If you go over |
|---|---|---|
| Inserts + updates + deletes | 150 per run (per chunk in Batch Mode) | the run stops |
| Outside web calls | 10 per run | the run stops |
| Run time | 15 seconds | the run stops |
| Rows from one query | 500 | you get the first 500 |
| Classes calling classes | 5 deep | the run stops |
| Runs per day | set by your plan | further runs are refused until tomorrow |
Batch Mode gives every chunk a fresh set of limits, which is how one job can process thousands of records.
20. What the sandbox doesn't allow #
| Not allowed | Do this instead |
|---|---|
Calls on a class name: Math.round(x), String.valueOf(x), LocalDate.now() | Call on a value: x.setScale(0, 4), x.toString(), new Date(); [a, b].max() |
A typed variable of your own class: CaseRow r = new CaseRow() | def r = new CaseRow() |
e.getMessage() or e.message on catch (Exception e) | def problem = e, then problem.getMessage() |
Files, network, threads, System, reflection, imports of those | Records through Platform; web calls through Named Credentials |
println | Platform.log(...) |
| Raw SQL | Platform.query; the Developer Console has a query tab for exploring |
Classes Behind Components #
21. Calling a class from a SmartLite Component #
A SmartLite Component is a screen or record-page panel built from a template and a little JavaScript (see the SmartLite Components Guide). It reaches the server only through class methods it imports, one line per method. Each import becomes an async function: what you pass it arrives as the method's params, and what the method returns comes back to the component.
class AccountContacts {
// A SmartLite Component on the Account page calls this with the account's ID
def getView(params) {
def accountId = params?.accountId
if (!accountId) {
return [contacts: [], count: 0, hasContacts: false]
}
def rows = Platform.query('contact', 'account_id', accountId)
def contacts = rows.collect { c ->
def name = ((c.first_name ?: '') + ' ' + (c.last_name ?: '')).trim()
[ id: c.hex_id, name: name, email: c.email ?: '' ]
}.sort { it.name.toLowerCase() }
return [contacts: contacts, count: contacts.size(), hasContacts: !contacts.isEmpty()]
}
}import getView from '@class/AccountContacts.getView';
export default class AccountContactList extends SmartLiteComponent {
view = { contacts: [], count: 0, hasContacts: false };
async connectedCallback() {
// this.recordId: the account the user is looking at
this.view = await getView({ accountId: this.recordId });
}
}| In the component | In the class |
|---|---|
import getView from '@class/AccountContacts.getView'; | The class AccountContacts and its public method getView. The class must be saved and active first. |
await getView({ accountId: this.recordId }) | params.accountId. On a record page, this.recordId is the ID of the record being shown. |
this.view = … | Whatever getView returns, as plain data: here view.count, view.contacts. |
The class computes, the component shows
Return exactly what the screen needs, ready to show: names already joined, amounts formatted, flags such as canEdit decided. Each button calls another method that does the work and returns the updated view. Then all the rules live here, and the component stays a few lines long.
Only public methods can be imported
A private method can't be imported; saving the component says AccountContacts has no method …. Every public method of an active class can be imported, so don't leave a method public if a screen shouldn't call it.
22. What a method receives #
The component sends its object as JSON, and the class gets it as a Map. Measured on the real engine, with the component call below:
const r = await echo({
qty: 2, price: 19.99, flag: true,
tags: ['vip', 'partner'], contact: { name: 'Ana' },
});class TypeTour {
Map echo(Map params) {
return [
qtyPlusOne : params.qty + 1, // 3: whole numbers stay numbers
priceExact : params.price.toString().toBigDecimal() * 3, // 59.97, exact: use this for money
flagIsTrue : params.flag == true, // true
tagCount : params.tags.size(), // 2: an array arrives as a List
firstTag : params.tags[0], // 'vip'
nestedName : params.contact.name, // 'Ana': an object arrives as a Map
missing : params.nothing, // null: no error
missingSafe: params.nothing?.trim() ?: 'fallback' // 'fallback'
]
}
}| Sent from the component | Arrives in the class as |
|---|---|
2, 19.99 | a number: arithmetic works. For money, convert first: params.price.toString().toBigDecimal() |
'text' | a String |
true / false | a boolean |
['a', 'b'] | a List |
{ name: 'Ana' } | a Map: params.contact.name |
| a key you didn't send | null, never an error: params.nothing |
'2026-10-24' | text, the same as a date field on a record |
Write params?.x
params?.accountId keeps working when the method is called with no parameters at all, e.g. by the Run button or a Workflow Rule.
23. What a method returns #
Whatever the method returns is turned into JSON for the component. Measured on the real engine:
class Shapes {
Map shapes(Map params) {
String name = 'Priya'
def row = new Row()
row.setLabel('Printer down')
return [
plain : 'Hello ' + name, // "Hello Priya"
gstring : "Hello ${name}", // "Hello Priya"
decimal : 19.99, // 19.99
division: 10 / 4, // 2.5
bool : true, // true
nothing : null, // null
list : [1, 'two', [three: 3]], // [1, "two", {"three": 3}]
date : new Date(0), // "1970-01-01T00:00:00.000+00:00"
model : row // {"label": "Printer down", "urgent": true}
]
}
}
class Row {
private String label
void setLabel(String v) { label = v }
String getLabel() { return label }
boolean getUrgent() { return label?.contains('down') }
}| Return | The component gets |
|---|---|
a Map: [ count: 3 ] | an object: r.count |
a List | an array |
text: 'Hello ' + name or "Hello ${name}" | text |
numbers, true/false, null | the same |
new Date() | text like 2026-10-24T09:30:00.000+00:00 |
| an object of your own class (a model class) | an object with one property per getter: getLabel() → label, getUrgent() → urgent |
24. The errors a component sees #
| In the class | In the component |
|---|---|
return [ ok: false, message: 'Name is required' ] | r.ok is false and r.message is your text, exactly. Best for anything the user should read. |
throw new IllegalArgumentException('name is required') | the await fails; e.message is IllegalArgumentException at line 4: name is required, with the line of the throw |
an unexpected error, e.g. x.trim() on null | NullPointerException at line 7: Cannot invoke method trim() on null object |
| a save refused by a rule, caught as in section 17 | your [ ok: false, message: … ], e.g. Object not found: nosuchobject |
| the method doesn't exist | a long No signature of method: … message (the component can't be saved with such an import, so you only see this from old code) |
| the class doesn't exist or isn't active | No active script found with API name 'X' |
The line number is the line in your class. When one class called another, it names the other one: at line 5 of PriceTools. In the component, catch both kinds:
async handleSave() {
this.busy = true;
this.message = '';
try {
const r = await createAccount({ name: this.name });
if (!r.ok) {
this.message = r.message; // a problem the class returned: show it as it is
return;
}
this.openRecord(r.id);
} catch (e) {
this.message = e.message; // the class threw: "IllegalArgumentException at line …: …"
} finally {
this.busy = false;
}
}Unhandled errors are shown
If the component doesn't catch a failed call, the message appears in a red box at the top of the component and in the browser console, so nothing fails silently.
25. Testing a method with parameters #
The Run button only calls execute(). To try another method with real parameters, call it from the Developer Console → Execute Script tab, which runs a few lines on their own:
// Developer Console → Execute Script. CRM here is the same toolbox as Platform.
def view = CRM.callClass('AccountContacts', 'getView', [accountId: 'ACC00002f00000001HAA'])
CRM.log(view.count)
CRM.log(view.contacts.collect { it.name })- Open Setup → Customization → Developer Console and choose Execute Script.
- Paste the lines, put a real record ID in (copy it from the record's address), and click Execute.
- The output shows each
CRM.logline, here 3 and the contacts' names, and any inserts, updates and deletes.
A test method on the class
You can also add a method that calls the others with sample values, and call it from execute() while you're building: the Run button then exercises the real method. Remove it, or make it harmless, before going live.
26. Saving a class: what's checked #
Saving compiles the class under the same sandbox it runs in, without running it, so most mistakes show before anything happens. The message starts with Compile error:, then each problem with its line and column in your code.
| You wrote | Saving says |
|---|---|
return [1, 2 } | Compile error: line 3, column 22: Unexpected input: '}' |
new File('x').text, System…, network or thread code | Compile error: Property access not allowed on [java.io.File] |
Math.min(a, b), String.valueOf(x) | a refusal of the call on a class name (section 20) |
| a second class with the same name | A class named 'X' already exists |
| more classes than your plan allows | Limit reached: your … plan allows N scripts |
Some mistakes only show when the code runs, because Groovy works out types then: a misspelt method or field (No signature of method …, No such property …), a variable used without being declared (section 2), or a call on null. Test each method once (section 25) after saving.
Renaming a class or a method
A class can't be renamed (delete it and create it again). If you rename a public method, every component importing it must be changed too: until then, saving that component says X has no method ….
27. Who can run a class #
- A class runs as the user who caused the run: the person using the component, clicking Run, or whose save fired the Workflow Rule. It sees and changes only what that user may see and change: object permissions, sharing and field-level security all apply, and a record they can't see comes back as
null. - A component may call only the methods it imports. The server refuses anything else, even if the component's code tried.
- But any signed-in user can call any active class directly, with their own permissions, outside a component. So a class must not rely on which screen calls it: check in the class what the user is asking for, e.g. that a record belongs to the account they passed, before changing it.
- A batch job runs as the user who started it. A daily run is started by the system, and shows as scheduler in the job list.
Never put secrets in a class
Keys and passwords for outside services belong in a Named Credential (used through Platform.httpGet and friends), not in the code, where anyone who can open the class can read them.
Complete Examples #
28. A complete class: Accounts and their Contacts #
Everything from the first sections in one class. It is written without def except on execute(), to show that explicit types work everywhere (write def instead of any type if you prefer). It has four methods: create an account with contacts, read an account with its contacts, move contacts between accounts, and a clean-up for the Run button or a Workflow Rule.
- Go to Setup → Customization → Classes and click New Class.
- Paste the code below into Source Code, leave Active ticked and click Save.
- Click Run on its row: that runs
execute()and links contacts with no account to the account whose website matches their email domain. - For the other methods, build the screen below (step 5), or try them from the Developer Console (section 25).
class AccountContactService {
// ── 1. Create an account with its contacts ────────────────────────────
// Page: callClass('AccountContactService', 'createWithContacts',
// { name: 'Acme', industry: 'Retail', contacts: [ { firstName: 'Ana', lastName: 'Diaz', email: 'ana@acme.com' } ] })
Map createWithContacts(Map params) {
String name = params.name?.trim()
if (!name) throw new IllegalArgumentException('Account name is required')
if (Platform.query('account', 'name', name)) {
return [ ok: false, message: 'An account called ' + name + ' already exists' ]
}
String accountId = Platform.insert('account', [
name : name,
industry: params.industry,
website : params.website,
type : 'Prospect'
])
List people = params.contacts ?: []
List rows = people.collect { Map p ->
[ first_name: p.firstName, last_name: p.lastName, email: p.email?.trim()?.toLowerCase(),
title: p.title, account_id: accountId ]
}
List contactIds = rows ? Platform.insertAll('contact', rows) : []
Platform.log('created ' + name + ' with ' + contactIds.size() + ' contacts')
return [ ok: true, accountId: accountId, contactIds: contactIds ]
}
// ── 2. Read an account and its contacts ───────────────────────────────
Map getWithContacts(Map params) {
Map account = Platform.getRecord('account', params.accountId)
if (account == null) return [ ok: false, message: 'Account not found' ]
List contacts = Platform.query('contact', 'account_id', params.accountId)
List people = contacts
.sort { Map a, Map b -> (a.last_name ?: '') <=> (b.last_name ?: '') }
.collect { Map c -> [ id: c.hex_id, name: fullName(c), email: c.email ?: '', title: c.title ?: '' ] }
return [ ok: true, name: account.name, industry: account.industry ?: '-',
contactCount: people.size(), contacts: people,
missingEmail: people.count { Map p -> !p.email } ]
}
// ── 3. Move every contact from one account to another ─────────────────
Map moveContacts(Map params) {
if (params.fromAccountId == params.toAccountId) return [ ok: false, message: 'Pick two different accounts' ]
if (Platform.getRecord('account', params.toAccountId) == null) return [ ok: false, message: 'Target account not found' ]
List contacts = Platform.query('contact', 'account_id', params.fromAccountId)
for (Map c : contacts) {
Platform.update(c.hex_id, [ account_id: params.toAccountId ])
}
return [ ok: true, moved: contacts.size() ]
}
// ── 4. Run button / Workflow Rule: link contacts that have no account ─
// A contact without an account is linked to the account whose website matches its email domain.
def execute() {
Map accountsByDomain = [:]
for (Map a : Platform.query('account')) {
String domain = domainOf(a.website)
if (domain) accountsByDomain[domain] = a.hex_id
}
int linked = 0
for (Map c : Platform.query('contact')) {
if (c.account_id || !c.email) continue
String accountId = accountsByDomain[c.email.substring(c.email.indexOf('@') + 1).toLowerCase()]
if (accountId) {
Platform.update(c.hex_id, [ account_id: accountId ])
linked++
}
}
Platform.log('linked ' + linked + ' contacts to their accounts')
return linked
}
// ── helpers ────────────────────────────────────────────────────────────
private String fullName(Map c) {
return ((c.first_name ?: '') + ' ' + (c.last_name ?: '')).trim()
}
// 'https://www.acme.com/about' -> 'acme.com'
private String domainOf(Object website) {
if (!website) return null
return website.toString().toLowerCase()
.replaceAll('^https?://', '').replaceAll('^www\\.', '').replaceAll('/.*$', '')
}
}How it reads, line by line #
| Code | Meaning |
|---|---|
Map createWithContacts(Map params) | A method that receives a map (what the page sent) and returns a map. |
String name = params.name?.trim() | A text variable; ?. avoids an error when no name was sent. |
throw new IllegalArgumentException(…) | Stops with a message the page shows. |
if (Platform.query('account', 'name', name)) | A non-empty list counts as true: an account with this name exists. |
people.collect { Map p -> [ … ] } | Turns what the page sent into contact records with the real field names. |
Platform.insertAll('contact', rows) | Creates all the contacts in one call and returns their IDs. |
.sort { Map a, Map b -> … } | Sorts by last name; <=> compares two values. |
for (Map c : …) { if (…) continue } | A loop that skips contacts that already have an account. |
private String fullName(Map c) | A helper only this class can call. |
Step 5: a screen that uses it #
- Go to Setup → Customization → SmartLite Components → New Component. API Name
newCustomer, Label New Customer. Under Where it can go, tick App page. - Paste the files below into the Template, JavaScript and Styles tabs, click Check, then Save.
- Go to Setup → Configuration → Tab Manager → New Custom Tab, Tab Type Page Tab, and choose New Customer under SmartLite Components (its route is
/c/newCustomer). Add the tab to your app. - Open the tab, fill in the form and click Create: the screen creates the account and contact, then shows the account with its contacts.
<template>
<h3>New customer</h3>
<form onsubmit={handleCreate}>
<input name="name" placeholder="Company name" value={form.name} oninput={handleInput}>
<input name="industry" placeholder="Industry (e.g. Retail)" value={form.industry} oninput={handleInput}>
<input name="first" placeholder="Contact first name" value={form.first} oninput={handleInput}>
<input name="last" placeholder="Contact last name" value={form.last} oninput={handleInput}>
<input name="email" placeholder="Contact email" value={form.email} oninput={handleInput}>
<button class="sl-primary" disabled={busy}>Create</button>
</form>
<p class="sl-error">{message}</p>
<template if:true={account}>
<h3>{account.name} ({account.industry})</h3>
<p class="sl-muted">{account.contactCount} contacts, {account.missingEmail} without an email</p>
<table>
<template for:each={account.contacts} for:item="c">
<tr key={c.id}><td>{c.name}</td><td>{c.title}</td><td>{c.email}</td></tr>
</template>
</table>
</template>
</template>import createWithContacts from '@class/AccountContactService.createWithContacts';
import getWithContacts from '@class/AccountContactService.getWithContacts';
export default class NewCustomer extends SmartLiteComponent {
form = { name: '', industry: '', first: '', last: '', email: '' };
busy = false;
message = '';
account = null;
// Every input has a name, so one handler keeps the whole form
handleInput(event) {
this.form = { ...this.form, [event.target.name]: event.target.value };
}
async handleCreate() {
this.busy = true;
this.message = '';
try {
const r = await createWithContacts({
name: this.form.name,
industry: this.form.industry,
contacts: [{ firstName: this.form.first, lastName: this.form.last, email: this.form.email }],
});
if (!r.ok) {
this.message = r.message;
return;
}
this.account = await getWithContacts({ accountId: r.accountId });
} catch (e) {
this.message = e.message;
} finally {
this.busy = false;
}
}
}form { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px; margin-bottom: 8px; }29. A complete class on a custom object: Projects #
The same patterns work on objects you create yourself. Their API names end in __c, and so do their custom fields; name is the built-in name field.
- Go to Setup → Configuration → Object Manager → New Object. Label Project, plural Projects. Its API name becomes
project__c. - On the new object's Fields tab, add: Account (Lookup to Account) →
account__c; Status (Picklist: Planned, Active, Overdue, Done) →status__c; Budget (Currency) →budget__c; End Date (Date) →end_date__c. - Check each field's API name on the Fields tab and use exactly those names in the code.
- Go to Setup → Customization → Classes → New Class, paste the code below and save.
- For the nightly check, open it in Developer Console → Saved Scripts, tick Batch Mode, set Object API Name to
project__c, tick Run Daily and save. - To show projects on each Account: Setup → Customization → SmartLite Components → New Component, API name
accountProjects, tick Record page of Account, paste the files below and save; then Object Manager → Account → Page Layouts → Add Custom Component and choose it.
class ProjectService {
// Create a project for an account
Map create(Map params) {
if (!params.name) throw new IllegalArgumentException('Project name is required')
if (Platform.getRecord('account', params.accountId) == null) {
return [ ok: false, message: 'Account not found' ]
}
String id = Platform.insert('project__c', [
name : params.name,
account__c : params.accountId,
status__c : 'Planned',
budget__c : params.budget ?: 0,
end_date__c: params.endDate
])
return [ ok: true, id: id ]
}
// An account's projects with totals, for an Account record component
Map forAccount(Map params) {
List projects = Platform.query('project__c', 'account__c', params.accountId)
List rows = projects.collect { Map p ->
[ id: p.hex_id, name: p.name, status: p.status__c ?: 'Planned',
budget: money(p.budget__c), ends: p.end_date__c ?: '-' ]
}
List active = rows.findAll { Map r -> r.status == 'Active' }
return [ projects: rows,
activeCount: active.size(),
activeBudget: active.sum { Map r -> r.budget } ?: 0 ]
}
// Batch Mode on project__c, Run Daily: mark active projects past their end date as Overdue
def execute(List batch) {
String today = day(0)
int overdue = 0
for (Map p : batch) {
String ends = p.end_date__c?.toString()?.take(10)
if (p.status__c == 'Active' && ends && ends < today) {
Platform.update(p.hex_id, [ status__c: 'Overdue' ])
overdue++
}
}
return overdue
}
private BigDecimal money(Object value) {
return value ? value.toString().toBigDecimal() : 0
}
private String day(int offset) {
return new Date(new Date().getTime() + offset * 86400000L).toInstant().toString().substring(0, 10)
}
}<template>
<template if:true={loading}><p class="sl-muted">Loading…</p></template>
<template if:false={loading}>
<p class="totals">{view.activeCount} active projects, budget {view.activeBudget}</p>
<template if:false={view.projects}><p class="sl-muted">No projects yet.</p></template>
<ul>
<template for:each={view.projects} for:item="p">
<li key={p.id}>{p.name} — {p.status}, ends {p.ends}</li>
</template>
</ul>
</template>
</template>import forAccount from '@class/ProjectService.forAccount';
export default class AccountProjects extends SmartLiteComponent {
view = { projects: [], activeCount: 0, activeBudget: 0 };
loading = true;
async connectedCallback() {
try {
this.view = await forAccount({ accountId: this.recordId });
} finally {
this.loading = false;
}
}
}Same class, three doors
create can be called from a screen, forAccount from a record-page component, and execute(batch) by a nightly batch. One class can serve all of them.
More examples
The Custom Classes & Pages trail has 50 more, each with click-by-click steps, on its How to Use tab. To show a class's results on screen, see the SmartLite Components Guide.