Key Takeaways
- Keep controllers focused on view-facing state and behavior: loading indicators, selections, user actions, and coordination with services.
- Inject services into multiple controllers to reuse business rules and data-access logic. Shared service instances do not automatically mean shared HTTP results.
- Use template bindings for ordinary UI updates, components for reusable UI units, and directives for custom DOM behavior.
- AngularJS is a legacy framework whose official support ended in 2022. Apply these practices to maintenance and refactoring, not as a recommendation for new applications.
AngularJS controller best practices come down to clear ownership: controllers manage what a view needs, services own reusable application logic, and directives or components encapsulate UI behavior. A controller can hold state and make HTTP requests; the problem is making it responsible for unrelated concerns.
AngularJS Controller Responsibilities: State and Behavior for a View
Think of a controller as a view model: the state and actions a template needs. AngularJS is often described as MVC-like, but treating its controllers as a catch-all for application logic misses the useful boundary.
A controller should initialize screen state, respond to user actions, call dependencies, and translate results into something the template can display. Dependency injection supplies those dependencies; it doesn't enforce good architecture for you. The official controller guide explicitly includes both initial state and behavior among controller responsibilities.
| Concern | Recommended home | Example |
|---|---|---|
| View-facing state and coordination | Controller or component controller | Loading state, selected row, save action |
| Reusable business rules | Service or a dependency used by a service | Eligibility checks used by several screens |
| Reusable data access | Injected service | Request construction and response mapping |
| Ordinary UI updates | Template bindings | Showing an error or disabling a button |
| Reusable UI and custom DOM integration | Component or directive | Project summary or third-party widget wrapper |
AngularJS legacy-application pattern
Template <-- bindings / actions --> Controller
|
injected service
|
reusable rules / HTTP
Template also composes:
components: reusable UI units
directives: custom DOM behaviorMake Ownership Explicit With controllerAs
Prefer a named view model such as ng-controller="ProjectsController as vm". With this controllerAs style, AngularJS exposes the controller instance under the chosen alias, so vm.loading visibly belongs to that controller. Existing $scope-based controllers remain valid; don't rewrite a stable screen just to change its syntax.
Keep frequently evaluated template expressions inexpensive and free of side effects. For the underlying mechanism, see how AngularJS watchers work.
Use Services to Abstract Shared Functionality
A service gives reusable behavior a named interface that multiple controllers can inject. That's useful even before you need shared state: callers can depend on projectsApi.load() rather than each knowing the endpoint and response shape.
AngularJS services are lazily instantiated and cached by the injector. Consumers of the same service within that injector receive the same instance; separate injectors do not automatically share it. The services guide explains reuse, and the providers guide describes the injector's cached instances.
A Controller and Service With Loading, Errors, and Retry
The following fragments assume your application already defines and bootstraps a module named legacyApp. Register the JavaScript before bootstrap, and place the HTML inside that application's root. The example endpoint, GET /api/projects, must return a JSON array of objects with unique id values and a name.
The service owns data access; the controller owns presentation state. The service also deliberately shares an in-flight promise, meaning a pending result that concurrent callers can await.
// Fragment: register with the existing legacyApp module.
angular.module('legacyApp')
.factory('projectsApi', ['$http', function($http) {
var inFlight = null;
return {
load: function() {
if (!inFlight) {
inFlight = $http.get('/api/projects', {cache: false})
.then(function(response) {
return response.data;
})
.finally(function() {
inFlight = null;
});
}
return inFlight;
}
};
}])
.controller('ProjectsController', [
'projectsApi',
function(projectsApi) {
var vm = this;
vm.projects = [];
vm.loading = false;
vm.error = null;
vm.load = function() {
if (vm.loading) {
return;
}
vm.loading = true;
vm.error = null;
return projectsApi.load()
.then(function(projects) {
vm.projects = projects;
})
.catch(function() {
vm.error = 'Could not load projects. Please try again.';
})
.finally(function() {
vm.loading = false;
});
};
vm.load();
}
]);<!-- Fragment: inside the existing legacyApp root. -->
<section ng-controller="ProjectsController as vm">
<button type="button"
ng-click="vm.load()"
ng-disabled="vm.loading">
Reload / retry
</button>
<p ng-if="vm.loading" role="status">Loading projects…</p>
<p ng-if="vm.error" role="alert">{{ vm.error }}</p>
<ul>
<li ng-repeat="project in vm.projects track by project.id">
{{ project.name }}
</li>
</ul>
</section>The controller clears its loading flag after success or failure and offers an explicit retry without an automatic retry loop. It preserves previously loaded projects if a reload fails. The service leaves failures rejected so each caller can decide how to present them.
The dependency-name arrays make injection safe when a minifier renames function parameters. Keep the strings and parameter order synchronized; see AngularJS's dependency annotation documentation.
Should Controllers Make HTTP Requests Directly?
Sometimes, yes. For a small, view-specific request, injecting $http directly can be simpler than adding a service with no meaningful responsibility. Doing so does not lock the response data inside the controller.
In the example above, the direct alternative would replace projectsApi.load() with the following expression and inject $http instead. Keep the controller's existing success, error, and loading handlers:
// Fragment: replacement expression inside vm.load().
$http.get('/api/projects', {cache: false})
.then(function(response) {
return response.data;
})Extract a service when request construction, response mapping, business rules, or request-sharing policy deserves reuse or independent testing. Keep screen-specific error wording and loading state near the screen.
Separate Service Reuse From HTTP Caching
AngularJS's own $http response caching is disabled by default; browser HTTP caching is a separate concern. Moving a request into a factory does not enable caching or deduplicate calls. These behaviors are documented in the official $http source and API documentation.
Our service explicitly returns the same promise while a request is pending. It clears that reference after either outcome, so a later call starts another $http request. This shares pending work, not a permanent result cache.
Concurrent callers receive the same returned array. Treat it as read-only or copy it before local editing. If you add result caching, define invalidation, freshness, and user or tenant boundaries explicitly. Don't cache a rejected promise indefinitely and accidentally make every retry fail immediately.
Save Custom DOM Manipulation for Directives and Components
Don't query the document or call jQuery from a page controller to update text, classes, or visibility. First express the desired state in the template. The loading message and disabled button above need bindings, not custom DOM code.
When you genuinely need DOM integration, such as initializing a third-party widget, encapsulate it in a directive. A directive's linking function is the appropriate place to work with its element. jQuery is not categorically forbidden; letting page controllers reach into unrelated DOM is the architectural problem. See the AngularJS directive guide.
Prefer Components for Reusable UI Units
AngularJS 1.5 introduced the component model. A component is a specialized directive with simpler configuration, an isolated scope, and a default controller alias of $ctrl. Use it for a UI unit with an explicit interface; use an ordinary directive when you need attribute behavior or linking functions. The component guide explains the distinction.
// Fragment: register with the existing legacyApp module.
angular.module('legacyApp').component('projectCount', {
bindings: {count: '<'},
template: '<p>{{ $ctrl.count }} projects</p>'
});<!-- Fragment: inside the ProjectsController template. -->
<project-count count="vm.projects.length"></project-count>This component needs no manual DOM updates. For component-owned DOM integration, $postLink() provides a lifecycle hook; release external resources in $onDestroy(). That is a deliberate exception to the page-controller rule, not permission to scatter DOM queries throughout controllers.
Test the Boundaries, Not Just the File Structure
Controllers can be unit-tested too. Moving code into a service doesn't automatically make the design testable: dependencies still need clear interfaces and observable outcomes. AngularJS's unit-testing guide covers controller testing and promise handling.
- Controller tests: inject a fake
projectsApi, resolve or reject its promise, and verify projects, loading state, and error state. - Service tests: use the
ngMockversion of$httpBackendto verify the endpoint, response mapping, concurrent request sharing, and a new request after failure. - UI tests: check that bindings render errors and disable retry while loading. Exercise widget initialization and teardown separately.
For $q-based promise tests, advance AngularJS's digest cycle before asserting callback results. Use $httpBackend.flush() to deliver mocked HTTP responses. Our $httpBackend testing tutorial provides a focused example.
Common Mistakes
- Moving all state into services: keep temporary screen state local unless sharing it is intentional.
- Assuming a shared service means one request: implement and test the exact sharing or caching policy you need.
- Hiding errors as empty results: distinguish “no projects” from “projects could not be loaded.”
- Retrying everything automatically: agree on retry limits and safe operations before adding a shared retry policy.
- Using controller cleanup as a security plan: better boundaries do not restore official framework support.
Make Targeted Fixes Without Losing the Migration Plan
For an isolated defect, make the smallest tested change: extract duplicated request logic, correct an error path, or move widget handling into a directive. Avoid a cross-application rewrite solely to standardize controller syntax.
For continuing feature development, establish a migration plan alongside maintenance work. Inventory security exposure, dependencies, test coverage, and team capacity; decide who owns ongoing risk. Component boundaries and service interfaces can make that work easier to divide, but they are not a migration by themselves.
If performance is the immediate problem, measure it separately from controller size. Start with our AngularJS performance best practices rather than assuming that moving code between files will make the application faster.
FAQ
Should an AngularJS controller call $http directly?
Yes, for a small, view-specific request. Move reusable request construction, response mapping, or shared policies into a service; keep loading and error display state in the controller.
Are AngularJS services singletons?
They are lazily instantiated and cached once per injector. Controllers using the same service through that injector receive the same instance; separate injectors do not automatically share it.
Does using a service automatically cache HTTP responses?
No. Registering a service does not cache its HTTP responses; configure AngularJS $http caching or implement explicit promise or result sharing when appropriate.
When should I use an AngularJS directive or component?
Use template bindings for ordinary UI updates and components for reusable UI units in AngularJS 1.5 and later. Use a directive when you need attribute behavior, linking functions, or a wrapper around a DOM plugin.
Is AngularJS still supported in 2026?
No, not by the Angular team: AngularJS has been out of official support since January 1, 2022. It is distinct from modern Angular, so assess your legacy application's security exposure, maintenance capacity, and migration options.
Further Reading
- AngularJS end-of-support announcement — official support status.
- Understanding AngularJS controllers — state, behavior, aliases, and testing.
- AngularJS services — reusable injectable dependencies.
- Official $http source and API documentation — promises, response objects, and caching behavior.
- AngularJS components — configuration, bindings, and lifecycle hooks.
- AngularJS directives — linking and DOM integration.
- Dependency injection documentation source — explicit annotations and minification safety.
- AngularJS unit testing — test isolation, controllers, and promises.
