You build a custom controller by writing an Apex class and pointing a Visualforce page at it. Here is the minimal path from empty class to a working page, with the choices that matter at each step.
- Create the Apex class
In Setup, go to Apex Classes and click New, or create the class in your IDE. Declare it public, add the with sharing keyword unless you have a documented reason not to, and give it a no-argument constructor. Run any initial queries in that constructor and store results in member variables.
- Expose data with properties
Add public properties using get; set; for anything the page reads or writes. A property named searchTerm becomes {!searchTerm} on the page. Keep getters cheap; do not run SOQL inside a getter that the page calls during rendering.
- Write action methods
Add public methods that return PageReference for each button or link. Return null to re-render in place, or a new PageReference to redirect. Run all DML inside these action methods, never inside getters.
- Bind the page to the class
Create the Visualforce page and set controller="YourClassName" on the apex:page tag. Reference your properties with merge expressions and wire buttons with action="{!yourMethod}". Wrap inputs in apex:form so postbacks work.
- Write the test class
Create an Apex test that instantiates the controller, sets properties, sets any URL parameters with ApexPages.currentPage().getParameters().put, calls the action methods, and asserts on the returned PageReference and the controller state.
The class must be public (or global). The controller attribute cannot bind to a private class.
The framework instantiates a custom controller with a parameterless constructor, so one must exist (the implicit default counts if you write no constructor at all).
The apex:page tag must set controller="ClassName" to link the page to the custom controller. This is what makes every expression resolve against your class.
Anything the page references, getters, setters, and action methods, must be public. Private members are invisible to the page and the expression will fail to resolve.
- Without with sharing, the controller runs in system mode and can surface records the user is not allowed to see.
- A getter that runs SOQL is called multiple times per render and will burn query limits fast; query once and cache in a variable.
- Large non-transient member variables inflate view state toward the 170 KB cap; mark anything not needed next request as transient.
- DML in a getter throws an error because rendering is read-only; move all inserts, updates, and deletes into action methods.