Django Service Objects’s documentation¶
What?¶
This is a small library providing a Service
base class to derive your service objects from. What are service
objects? You can read more about the whys and hows in this
blog post, but for the most part, it encapsulates your business
logic, decoupling it from your views and model methods. Service objects are where your business logic should go.
Installation¶
Like every other Python Package
$ pip install django-service-objects
Philosophy¶
Decoupled from presentation logic¶
Now that your business logic is in its own class, you can call it from anywhere. From regular Django views, JSON endpoints, management commands, RQ tasks, Django admin, etc. Your business logic is no longer bound to your views or models.
Easily testable and mockable¶
Since service objects are just objects, you can easily call them from your tests to verify their behavior. Similarly, if you’re testing your view or endpoint, you can easily mock out the service objects.
Input validation¶
Your code will become a lot more concise now that you don’t have to manually check whether or not a parameter is valid.
If you need to know why your inputs failed validation, just catch InvalidInputsError
and access the
errors
or non_field_errors
dictionary. Better yet, raise a custom exception from your service object.
But fat models…¶
Models just ended up doing too many things. Plus it wasn’t very clear which model a method belongs to if it operated on
two different models. (Should Booking
have create_booking()
or should Customer
)?
Usage¶
Service¶
Super easy. Just:
- Create a new class that inherits from
Service
- Add some fields, exactly like how you would with Django Forms
- Define a
process()
method that contains your business logic - Optionally include a
post_process()
method to perform extra tasks. If using db_transaction = True, this will run after the process using a Django transaction on commit hook which will only run if the transaction is successfully committed. If using db_transaction = False, this will run after the process as long as there is no unhandled exceptions. The post_process is useful to running a task that should only be run if the process is successful (e.g. send an email, invoke a celery task, etc.).
A code sample is worth a thousand words.
class CreateBookingService(Service):
name = forms.CharField()
email = forms.EmailField()
checkin_date = forms.DateField()
checkout_date = forms.DateField()
def process(self):
name = self.cleaned_data['name']
email = self.cleaned_data['email']
checkin_date = self.cleaned_data['checkin_date']
checkout_date = self.cleaned_data['checkout_date']
# Update or create a customer
customer = Customer.objects.update_or_create(
email=email,
defaults={
'name': name
}
)
# Create booking
self.booking = Booking.objects.create(
customer=customer,
checkin_date=checkin_date,
checkout_date=checkout_date,
status=Booking.PENDING_VERIFICATION,
)
return self.booking
def post_process(self):
# Send verification email (check out django-herald)
VerifyEmailNotification(self.booking).send()
Database transactions¶
By default, the process method on services runs inside a transaction. This is so
that if an exception is raised while executing your service, the database gets
rolled back to a clean state. If you don’t want this behavior, you can set
db_transaction = False
on the service class.
class NoDbTransactionService(Service):
db_transaction = False
Function Based View¶
from django.shortcuts import redirect, render
from .forms import BookingForm
from .services import CreateBookingService
def create_booking_view(request):
form = BookingForm()
if request.method == 'POST':
form = BookingForm(request.POST)
if form.is_valid():
try:
# Services raise InvalidInputsError if you pass
# invalid values into it.
CreateBookingService.execute({
'name': form.cleaned_data['name'],
'email': form.cleaned_data['email'],
'checkin_date': form.cleaned_data['checkin_date'],
'checkout_date': form.cleaned_data['checkout_date'],
})
return redirect('booking:success')
except Exception as e:
form.add_error(None, f'Sorry. Something went wrong: {e}')
return render(request, 'booking/create_booking.html', {'form': form})
Class Based View¶
from django.core.urlresolvers import reverse_lazy
from service_objects.views import ServiceView
from .forms import BookingForm
from .services import CreateBookingService
class CreateBookingView(ServiceView):
form_class = BookingForm
service_class = CreateBookingService
template_name = 'booking/create_booking.html'
success_url = reverse_lazy('booking:success')
Testing¶
An example of testing CreateBookingService
from datetime import date
from django.core import mail
from django.test import TestCase
from .models import Booking, Customer
from .services import CreateBookingService
class CreateBookingServiceTest(TestCase):
def test_create_booking(self):
inputs = {
'name': 'John Doe',
'email': 'john@doe.com',
'checkin_date': date(2017, 8, 13),
'checkout_date': date(2017, 8, 15),
}
booking = CreateBookingService.execute(inputs)
# Test that a Customer gets created
customer = Customer.objects.get()
self.assertEqual(customer.name, inputs['name'])
self.assertEqual(customer.email, inputs['email'])
# Test that a Booking gets created
booking = Booking.objects.get()
self.assertEqual(customer, booking.customer)
self.assertEqual(booking.checkin_date, inputs['checkin_date'])
self.assertEqual(booking.checkout_date, inputs['checkout_date'])
# Test that the verification email gets sent
self.assertEqual(1, len(mail.outbox))
email = mail.outbox[0]
self.assertIn('verify email address', email.body)
Object Reference¶
Errors module¶
-
exception
service_objects.errors.
InvalidInputsError
(errors, non_field_errors)¶ Raised during
Service
’sservice_clean()
method. Encapsulates both field_errors and non_field_errors into a single entity.Parameters: - errors (dictionary) –
Services
’serrors
dictionary - non_field_errors (dictionary) –
Service
’snon_field_errors
dictionary
- errors (dictionary) –
Fields module¶
-
class
service_objects.fields.
DictField
(*, required=True, widget=None, label=None, initial=None, help_text='', error_messages=None, show_hidden_initial=False, validators=(), localize=False, disabled=False, label_suffix=None)¶ A field for
Service
that accepts a dictionary:- class PDFGenerate(Service):
context = DictField()
- process(self):
- context = self.cleaned_data[‘context’]
- PDFGenerate.execute({
- ‘context’: {‘a’: 1, ‘b’: 2}
})
-
clean
(value)¶ Validate the given value and return its “cleaned” value as an appropriate Python object. Raise ValidationError for any errors.
-
class
service_objects.fields.
ListField
(*, required=True, widget=None, label=None, initial=None, help_text='', error_messages=None, show_hidden_initial=False, validators=(), localize=False, disabled=False, label_suffix=None)¶ A field for
Service
that accepts a list:- class EmailWelcomeMessage(Service):
emails = ListField()
- process(self):
- emails = self.cleaned_data[‘emails’]
- EmailWelcomeMessage.execute({
- ‘emails’: [‘blue@test.com’, ‘red@test.com’]
})
-
clean
(value)¶ Validate the given value and return its “cleaned” value as an appropriate Python object. Raise ValidationError for any errors.
-
class
service_objects.fields.
ModelField
(model_class, allow_unsaved=False, *args, **kwargs)¶ A field for
Service
that accepts an object of the specifiedModel
:class Person(models.Model): first_name = models.CharField(max_length=30) last_name = models.CharField(max_length=30) last_updated = models.DateTimeField() class UpdatePerson(Service): person = ModelField(Person) process(self): person = self.cleaned_data['person'] person.last_updated = now() person.save() user = Person(first_name='John', last_name='Smith') user.save() UpdatePerson.execute({ 'person': user })
Parameters: - model_class – Django
Model
or dotted string of : class:Model name - allow_unsaved – Whether the object is required to be saved to the database
-
clean
(value)¶ Validate the given value and return its “cleaned” value as an appropriate Python object. Raise ValidationError for any errors.
- model_class – Django
-
class
service_objects.fields.
MultipleFormField
(form_class, min_count=1, max_count=None, *args, **kwargs)¶ A field for
Service
that accepts a list of objects which is translated into multipleForm
objects:class PersonForm(forms.Form): name = forms.CharField() class UpdateOrganizationService(Service): people = MultipleFormField(PersonForm) def process(self): people = self.cleaned_data['people'] for person in people: print(person.cleaned_data['name']) UpdateOrganizationService.execute({ 'people': [ { 'name': 'John Smith' }, { 'name': 'Adam Davis' }, ] })
-
clean
(values)¶ Validate the given value and return its “cleaned” value as an appropriate Python object. Raise ValidationError for any errors.
-
-
class
service_objects.fields.
MultipleModelField
(model_class, allow_unsaved=False, *args, **kwargs)¶ A multiple model version of
ModelField
, will check each passed in object to match the specifiedModel
:class Person(models.Model): first_name = models.CharField(max_length=30) last_name = models.CharField(max_length=30) class AssociatePeople(Service): people = MultipleModelField(Person, allow_unsaved=True) users = [ Person(first_name='John', last_name='Smith'), Person(first_name='Jane', last_name='Smith') ] AssociatePeople.execute({ 'people': users }) for user in users: user.save()
Parameters: - model_class – Django
Model
or dotted string of : class:Model name - allow_unsaved – Whether the object is required to be saved to the database
-
clean
(values)¶ Validate the given value and return its “cleaned” value as an appropriate Python object. Raise ValidationError for any errors.
- model_class – Django
Services module¶
-
class
service_objects.services.
Service
(data=None, files=None, auto_id='id_%s', prefix=None, initial=None, error_class=<class 'django.forms.utils.ErrorList'>, label_suffix=None, empty_permitted=False, field_order=None, use_required_attribute=None, renderer=None)¶ Based on Django’s
Form
, designed to encapsulate Business Rules functionality. Input values are validated against the Service’s defined fields before calling main functionality:class UpdateUserEmail(Service): user = ModelField(User) new_email = forms.EmailField() def process(self): old_email = user.email user.email = self.cleaned_data['new_email'] user.save() send_email( 'Email Update', 'Your email was changed', 'system', [old_email] ) user = User.objects.get(id=20) UpdateUserEmail.execute({ 'user': user, 'new_email': 'John.Smith@example.com' })
Variables: - db_transaction (boolean) – controls if
execute()
is performed inside a Django database transaction. Default is True. - using (string) – In a multiple database setup, controls which database connection is used from the transaction. Defaults to DEFAULT_DB_ALIAS which works in a single database setup.
-
classmethod
execute
(inputs, files=None, **kwargs)¶ Function to be called from the outside to kick off the Service functionality.
Parameters: - inputs (dictionary) – data parameters for Service, checked against the fields defined on the Service class.
- files (dictionary) – usually request’s FILES dictionary or None.
- **kwargs (dictionary) –
any additional parameters Service may need, can be an empty dictionary
-
post_process
()¶ Post process method to be perform extra actions once
process()
successfully executes.
-
process
()¶ Main method to be overridden; contains the Business Rules functionality.
-
service_clean
()¶ Calls base Form’s
is_valid()
to verifyinputs
against Service’s fields and raisesInvalidInputsError
if necessary.
- db_transaction (boolean) – controls if
-
class
service_objects.services.
ModelService
(data=None, files=None, auto_id='id_%s', prefix=None, initial=None, error_class=<class 'django.forms.utils.ErrorList'>, label_suffix=None, empty_permitted=False, field_order=None, use_required_attribute=None, renderer=None)¶ Same as
Service
but auto-creates fields based on the providedModel
. Additionally, You can manually create fields to override or extend the auto-created fields:class Person(models.Model): first_name = models.CharField(max_length=30) last_name = models.CharField(max_length=30) email = models.EmailField() class CreatePersonService(Service): class Meta: model = Person fields = '_all_' notify = forms.BooleanField() def process(self): person = Person( first_name = self.cleaned_data['first_name'], last_name = self.cleaned_data['last_name'], email = self.cleaned_data['email'] ) person.save() if self.cleaned_data['notify']: django.send_mail( 'Account Created', 'An account has been created for you' 'System', [person.email] ) CreatePersonService.execute({ 'first_name': 'John', 'last_name': 'Smith', 'notify': True })
Views module¶
-
class
service_objects.views.
CreateServiceView
(**kwargs)¶ Based on Django’s
CreateView
, designed to call theService
class if the form is valid.
-
class
service_objects.views.
ServiceView
(**kwargs)¶ Based on Django’s
FormView
, designed to call aService
class if the Form is valid. Ifform_class
isNone
, ServiceView will useservice_class
for the Form to present the UI to the User:from django.core.urlresolvers import reverse_lazy from service_objects.views import ServiceView from .forms import BookingForm from .services import CreateBookingService class CreateBookingView(ServiceView): form_class = BookingForm service_class = CreateBookingService template_name = 'booking/create_booking.html' success_url = reverse_lazy('booking:success')
-
class
service_objects.views.
UpdateServiceView
(**kwargs)¶ Based on Django’s
UpdateView
, designed to call theService
class if the form is valid.
Support¶
Supported Version¶
Tests are run on officially supported versions of Django
- Python 2.7, 3.5, 3.6, 3.7
- Django 1.11, 2.0, 2.1, 2.2, 3.0
Contributing¶
We welcome new functionality and bugfixes! Everything should be submitted as a GitHub Pull Request. If you want to be sure that the change will be accepted before starting then open a GitHub Issue to have a discussion.
Pull Request Process¶
- Ensure any install or build dependencies are removed before the end of the layer when doing a build.
- Include/update relevant documenation changes.
- Include/update relevants test changes.
- Make sure all tests are passing.
- Make sure code is passing
flake8