diff --git a/maubot/loader/__init__.py b/maubot/loader/__init__.py
new file mode 100644
index 0000000..552e342
--- /dev/null
+++ b/maubot/loader/__init__.py
@@ -0,0 +1,2 @@
+from .abc import PluginLoader, PluginClass
+from .zip import ZippedPluginLoader
diff --git a/maubot/loader/abc.py b/maubot/loader/abc.py
new file mode 100644
index 0000000..e7b323d
--- /dev/null
+++ b/maubot/loader/abc.py
@@ -0,0 +1,37 @@
+# maubot - A plugin-based Matrix bot system.
+# Copyright (C) 2018 Tulir Asokan
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+from typing import TypeVar, Type
+from abc import ABC, abstractmethod
+from ..plugin_base import Plugin
+
+PluginClass = TypeVar("PluginClass", bound=Plugin)
+
+
+class PluginLoader(ABC):
+    id: str
+    version: str
+
+    @abstractmethod
+    def load(self) -> Type[PluginClass]:
+        pass
+
+    @abstractmethod
+    def reload(self) -> Type[PluginClass]:
+        pass
+
+    @abstractmethod
+    def unload(self) -> None:
+        pass
diff --git a/maubot/loader/zip.py b/maubot/loader/zip.py
new file mode 100644
index 0000000..bb44979
--- /dev/null
+++ b/maubot/loader/zip.py
@@ -0,0 +1,144 @@
+# maubot - A plugin-based Matrix bot system.
+# Copyright (C) 2018 Tulir Asokan
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+from typing import Dict, List, Type
+from zipfile import ZipFile, BadZipFile
+import sys
+import configparser
+
+from ..lib.zipimport import zipimporter, ZipImportError
+from ..plugin_base import Plugin
+from .abc import PluginLoader, PluginClass
+
+
+class MaubotZipImportError(Exception):
+    pass
+
+
+class ZippedPluginLoader(PluginLoader):
+    path_cache: Dict[str, 'ZippedPluginLoader'] = {}
+    id_cache: Dict[str, 'ZippedPluginLoader'] = {}
+
+    path: str
+    id: str
+    version: str
+    modules: List[str]
+    main_class: str
+    main_module: str
+    loaded: bool
+    _importer: zipimporter
+
+    def __init__(self, path: str) -> None:
+        self.path = path
+        self.id = None
+        self.loaded = False
+        self._load_meta()
+        self._run_preload_checks(self._get_importer())
+        self.path_cache[self.path] = self
+        self.id_cache[self.id] = self
+
+    def __repr__(self) -> str:
+        return ("<ZippedPlugin "
+                f"path='{self.path}' "
+                f"id='{self.id}' "
+                f"loaded={self.loaded}>")
+
+    def _load_meta(self) -> None:
+        try:
+            file = ZipFile(self.path)
+            data = file.read("maubot.ini")
+        except FileNotFoundError as e:
+            raise MaubotZipImportError(f"Maubot plugin not found at {self.path}") from e
+        except BadZipFile as e:
+            raise MaubotZipImportError(f"File at {self.path} is not a maubot plugin") from e
+        except KeyError as e:
+            raise MaubotZipImportError(
+                "File at {path} does not contain a maubot plugin definition") from e
+        config = configparser.ConfigParser()
+        try:
+            config.read_string(data.decode("utf-8"), source=f"{self.path}/maubot.ini")
+            meta = config["maubot"]
+            meta_id = meta["ID"]
+            version = meta["Version"]
+            modules = [mod.strip() for mod in meta["Modules"].split(",")]
+            main_class = meta["MainClass"]
+            main_module = modules[-1]
+            if "/" in main_class:
+                main_module, main_class = main_class.split("/")[:2]
+        except (configparser.Error, KeyError, IndexError, ValueError) as e:
+            raise MaubotZipImportError(
+                f"Maubot plugin definition in file at {self.path} is invalid") from e
+        if self.id and meta_id != self.id:
+            raise MaubotZipImportError("Maubot plugin ID changed during reload")
+        self.id, self.version, self.modules = meta_id, version, modules
+        self.main_class, self.main_module = main_class, main_module
+
+    def _get_importer(self, reset_cache: bool = False) -> zipimporter:
+        try:
+            importer = zipimporter(self.path)
+            if reset_cache:
+                importer.reset_cache()
+            return importer
+        except ZipImportError as e:
+            raise MaubotZipImportError(
+                f"File at {self.path} not found or not a maubot plugin") from e
+
+    def _run_preload_checks(self, importer: zipimporter) -> None:
+        try:
+            code = importer.get_code(self.main_module.replace(".", "/"))
+            if self.main_class not in code.co_names:
+                raise MaubotZipImportError(
+                    f"Main class {self.main_class} not in {self.main_module}")
+        except ZipImportError as e:
+            raise MaubotZipImportError(
+                f"Main module {self.main_module} not found in {self.path}") from e
+        for module in self.modules:
+            try:
+                importer.find_module(module)
+            except ZipImportError as e:
+                raise MaubotZipImportError(f"Module {module} not found in {self.path}") from e
+
+    def load(self) -> Type[PluginClass]:
+        importer = self._get_importer(reset_cache=self.loaded)
+        self._run_preload_checks(importer)
+        for module in self.modules:
+            importer.load_module(module)
+        self.loaded = True
+        main_mod = sys.modules[self.main_module]
+        plugin = getattr(main_mod, self.main_class)
+        if not issubclass(plugin, Plugin):
+            raise MaubotZipImportError(
+                f"Main class of plugin at {self.path} does not extend maubot.Plugin")
+        return plugin
+
+    def reload(self) -> Type[PluginClass]:
+        self.unload()
+        return self.load()
+
+    def unload(self) -> None:
+        for name, mod in list(sys.modules.items()):
+            if getattr(mod, "__file__", "").startswith(self.path):
+                del sys.modules[name]
+
+    def destroy(self) -> None:
+        self.unload()
+        try:
+            del self.path_cache[self.path]
+        except KeyError:
+            pass
+        try:
+            del self.id_cache[self.id]
+        except KeyError:
+            pass
diff --git a/maubot/plugin.py b/maubot/plugin.py
index b952af0..36823a0 100644
--- a/maubot/plugin.py
+++ b/maubot/plugin.py
@@ -13,140 +13,14 @@
 #
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
-from typing import Dict, List, TypeVar, Type
-from zipfile import ZipFile, BadZipFile
+from typing import Dict, List
 import logging
-import sys
-import os
-import io
-import configparser
 
 from mautrix.types import UserID
 
-from .lib.zipimport import zipimporter, ZipImportError
 from .db import DBPlugin
-from .plugin_base import Plugin
 
 log = logging.getLogger("maubot.plugin")
-PluginClass = TypeVar("PluginClass", bound=Plugin)
-
-class MaubotImportError(Exception):
-    pass
-
-
-class ZippedModule:
-    path_cache: Dict[str, 'ZippedModule'] = {}
-    id_cache: Dict[str, 'ZippedModule'] = {}
-
-    path: str
-    id: str
-    version: str
-    modules: List[str]
-    main_class: str
-    main_module: str
-    loaded: bool
-    _importer: zipimporter
-
-    def __init__(self, path: str) -> None:
-        self.path = path
-        self.id = None
-        self.loaded = False
-        self._load_meta()
-        self._run_preload_checks(self._get_importer())
-        self.path_cache[self.path] = self
-        self.id_cache[self.id] = self
-
-    def __repr__(self) -> str:
-        return ("<maubot.plugin.ZippedModule "
-                f"path='{self.path}' "
-                f"id='{self.id}' "
-                f"loaded={self.loaded}>")
-
-    def _load_meta(self) -> None:
-        try:
-            file = ZipFile(self.path)
-            data = file.read("maubot.ini")
-        except FileNotFoundError as e:
-            raise MaubotImportError(f"Maubot plugin not found at {self.path}") from e
-        except BadZipFile as e:
-            raise MaubotImportError(f"File at {self.path} is not a maubot plugin") from e
-        except KeyError as e:
-            raise MaubotImportError(
-                "File at {path} does not contain a maubot plugin definition") from e
-        config = configparser.ConfigParser()
-        try:
-            config.read_string(data.decode("utf-8"), source=f"{self.path}/maubot.ini")
-            meta = config["maubot"]
-            meta_id = meta["ID"]
-            version = meta["Version"]
-            modules = [mod.strip() for mod in meta["Modules"].split(",")]
-            main_class = meta["MainClass"]
-            main_module = modules[-1]
-            if "/" in main_class:
-                main_module, main_class = main_class.split("/")[:2]
-        except (configparser.Error, KeyError, IndexError, ValueError) as e:
-            raise MaubotImportError(
-                f"Maubot plugin definition in file at {self.path} is invalid") from e
-        if self.id and meta_id != self.id:
-            raise MaubotImportError("Maubot plugin ID changed during reload")
-        self.id, self.version, self.modules = meta_id, version, modules
-        self.main_class, self.main_module = main_class, main_module
-
-    def _get_importer(self, reset_cache: bool = False) -> zipimporter:
-        try:
-            importer = zipimporter(self.path)
-            if reset_cache:
-                importer.reset_cache()
-            return importer
-        except ZipImportError as e:
-            raise MaubotImportError(f"File at {self.path} not found or not a maubot plugin") from e
-
-    def _run_preload_checks(self, importer: zipimporter) -> None:
-        try:
-            code = importer.get_code(self.main_module.replace(".", "/"))
-            if self.main_class not in code.co_names:
-                raise MaubotImportError(f"Main class {self.main_class} not in {self.main_module}")
-        except ZipImportError as e:
-            raise MaubotImportError(
-                f"Main module {self.main_module} not found in {self.path}") from e
-        for module in self.modules:
-            try:
-                importer.find_module(module)
-            except ZipImportError as e:
-                raise MaubotImportError(f"Module {module} not found in {self.path}") from e
-
-    def load(self) -> Type[PluginClass]:
-        importer = self._get_importer(reset_cache=self.loaded)
-        self._run_preload_checks(importer)
-        for module in self.modules:
-            importer.load_module(module)
-        self.loaded = True
-        main_mod = sys.modules[self.main_module]
-        plugin = getattr(main_mod, self.main_class)
-        if not issubclass(plugin, Plugin):
-            raise MaubotImportError(
-                f"Main class of plugin at {self.path} does not extend maubot.Plugin")
-        return plugin
-
-    def reload(self) -> Type[PluginClass]:
-        self.unload()
-        return self.load()
-
-    def unload(self) -> None:
-        for name, mod in list(sys.modules.items()):
-            if getattr(mod, "__file__", "").startswith(self.path):
-                del sys.modules[name]
-
-    def destroy(self) -> None:
-        self.unload()
-        try:
-            del self.path_cache[self.path]
-        except KeyError:
-            pass
-        try:
-            del self.id_cache[self.id]
-        except KeyError:
-            pass
 
 
 class PluginInstance:
@@ -157,6 +31,8 @@ class PluginInstance:
         self.db_instance = db_instance
         self.cache[self.id] = self
 
+    # region Properties
+
     @property
     def id(self) -> str:
         return self.db_instance.id
@@ -188,3 +64,5 @@ class PluginInstance:
     @primary_user.setter
     def primary_user(self, value: UserID) -> None:
         self.db_instance.primary_user = value
+
+    # endregion