Angular Delegator
The Story of the Missing Abstraction
@markdalgleish
The Problem
Splitting large services into smaller ones
- It's good software design, but...
- It requires a lot of glue code and repetitive tests.
The Missing Abstraction
Prologue
We Were Writing a Search App
Filters Model
The core model in our app
app.value('filters', {
keywords: "",
sortBy: "relevance",
workTypes: [],
...
});
URL Parameters
Filters model needs to be serialized and deserialized:
/results?keywords=foo&workTypes=0,1,2
Whenever the route changes
We need to sync the filters model with the URL
$rootScope.$on('$stateChangeSuccess',
function(event, toState, toParams) {
_.extend(filters, ParamsAdapter.toFilters(toParams));
}
);
(We're using ui-router)
ParamsAdapter
Two methods:
- toParams
- fromParams
It started small
Then it grew and grew
And the tests grew. And grew.
It started to suck.
Separate those concerns!
Let's break it up into smaller services
- KeywordsParamsAdapter
- SalaryParamsAdapter
- WorkTypeParamsAdapter
- etc...
We're On The Right Track...
Lots of small services with the same interface
So what does ParamsAdapter look like?
app.service('ParamsAdapter', function(Foo, Bar, Baz) {
this.toFilters = function(params) {
return _.extend({},
Foo.toFilters(params),
Bar.toFilters(params),
Baz.toFilters(params)
);
};
this.fromFilters = function(filters) {
return _.extend({},
Foo.fromFilters(filters),
Bar.fromFilters(filters),
Baz.fromFilters(filters)
);
};
});
This seemed verbose, but OK
Until we wrote the tests
- Lots of repetitive boilerplate
- Adding new adapters = lots of copy + paste
The Missing Abstraction
Act One
Configuring Service Arrays
Arrays of services
Configured with Angular Delegator
app.config(function(DelegatorProvider) {
DelegatorProvider.set('ParamsAdapters', [
'KeywordsParamsAdapter',
'SalaryParamsAdapter',
'WorkTypeParamsAdapter',
...
]);
});
We can now reference this service array
Inside our 'ParamsAdapter' service
app.service('ParamsAdapter', function(Delegator) {
this.toFilters = function(params) {
// Merge the results of each service into a single object:
return _.extend.apply(_,
_.map(Delegator.get('ParamsAdapters'), function(delegate) {
return delegate.toFilters(params);
})
);
};
// ...and again for 'fromFilters'
});
This is better, but it's starting to look like
Framework code
The Missing Abstraction
Act Two
Angular Delegator Gets Smarter
Let's work at a higher level
We're manually creating a service that talks to 'Delegator'
What if...
Angular Delegator could create the service for us?
app.config(function(DelegatorProvider) {
DelegatorProvider.service('ParamsAdaptersDelegator', {
interface: {
'toFilters': 'merge',
'fromFilters': 'merge'
},
delegates: [
'KeywordsParamsAdapter',
'SalaryParamsAdapter',
'WorkTypeParamsAdapter',
...
]
);
});
Now we're free to delete code
We no longer need 'ParamsAdapter' or its test
The Missing Abstraction
Act Three
Angular Delegator Proves Itself
We now have a clean way to break up our services
We started to notice other uses of this pattern
Active filter pills
| "Foobar" (X) | Part Time, Casual (X) |
- Each pill needs a model with "text" and "reset"
- There might only be a couple of pills visible
app.config(function(DelegatorProvider) {
DelegatorProvider.service('PillPresenterDelegator', {
interface: {
'present': 'truthy'
},
delegates: [
'KeywordsPillPresenter',
'SortByPillPresenter',
'WorkTypePillPresenter',
...
]
);
});
The delegator runs all pill presenters
And iterates over the results:
<div ng-repeat="pill in pillPresenters">
<div>{{ pill.text }}</div>
<div ng-click="pill.reset()">X</div>
</div>
Any other obvious uses?
Validation
app.config(function(DelegatorProvider) {
DelegatorProvider.service('FilterValidatorDelegator', {
interface: {
'isValid': 'all'
},
delegates: [
'KeywordsFilterValidator',
'SortByFilterValidator',
'WorkTypeFilterValidator',
...
]
);
});
Delegator Strategies
- map
- merge
- truthy
- any
- all
- none
- custom!
Creating new strategies
They're just services
app.service('TextDelegatorStrategy', function(MapDelegatorStrategy) {
return function(services, args) {
return MapDelegatorStrategy(services, args).join(', ');
};
});
interface: {
'toText': 'text'
}
Moral of the story
Want to split up a large service into smaller ones?
Reach for angular-delegator
$ bower install --save angular-delegator